frosty/lib/widgets/follow_button.dart
copilot 9a08c40136 Add Follow/Unfollow button under the channel player.
Show channel info and a follow control between video and chat in portrait, reusing the shared GQL follow path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 09:49:45 +02:00

206 lines
5.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/screens/onboarding/login_webview.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/follow_channel_controller.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:frosty/widgets/integrity_webview_dialog.dart';
/// Compact Follow / Unfollow control for channel headers.
class FollowButton extends StatefulWidget {
final AuthStore authStore;
final String userId;
final String displayName;
final bool? initialIsFollowing;
final ValueChanged<bool>? onFollowChanged;
const FollowButton({
super.key,
required this.authStore,
required this.userId,
required this.displayName,
this.initialIsFollowing,
this.onFollowChanged,
});
@override
State<FollowButton> createState() => _FollowButtonState();
}
class _FollowButtonState extends State<FollowButton> {
late final FollowChannelController _controller = FollowChannelController(
authStore: widget.authStore,
userId: widget.userId,
initialIsFollowing: widget.initialIsFollowing,
onChanged: () {
if (mounted) setState(() {});
},
);
@override
void initState() {
super.initState();
_controller.init();
}
Future<void> _onPressed() async {
if (!_controller.canFollow ||
_controller.isFollowing == null ||
_controller.actionInProgress) {
return;
}
final wasFollowing = _controller.isFollowing!;
try {
final nowFollowing = await _controller.toggle();
if (!mounted) return;
widget.onFollowChanged?.call(nowFollowing);
_showResultSnack(nowFollowing);
} on FollowNeedsWebSessionException {
if (!mounted) return;
await _promptLinkWebSession();
} on IntegrityCheckException {
if (!mounted) return;
await _handleIntegrity(wasFollowing: wasFollowing);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.displayName}'
: 'Could not follow ${widget.displayName}',
);
}
}
Future<void> _handleIntegrity({required bool wasFollowing}) async {
final gqlToken = widget.authStore.gqlToken;
if (gqlToken == null) {
_controller.clearActionInProgress();
await _promptLinkWebSession();
return;
}
final credentials = await showIntegrityWebViewDialog(
context: context,
gqlToken: gqlToken,
);
if (!mounted) return;
if (credentials == null) {
_controller.clearActionInProgress();
_showErrorSnack('Follow cancelled — Twitch verification required');
return;
}
try {
final nowFollowing = await _controller.retryAfterIntegrity(
wasFollowing: wasFollowing,
credentials: credentials,
);
if (!mounted) return;
widget.onFollowChanged?.call(nowFollowing);
_showResultSnack(nowFollowing);
} on ApiException catch (e) {
if (!mounted) return;
_showErrorSnack(e.message);
} catch (_) {
if (!mounted) return;
_showErrorSnack(
wasFollowing
? 'Could not unfollow ${widget.displayName}'
: 'Could not follow ${widget.displayName}',
);
}
}
Future<void> _promptLinkWebSession() {
return showDialog<void>(
context: context,
builder: (dialogContext) => FrostyDialog(
title: 'Web session required',
message:
'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.',
actions: [
TextButton(
onPressed: Navigator.of(dialogContext).pop,
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LoginWebView()),
);
},
child: const Text('Log in'),
),
],
),
);
}
void _showResultSnack(bool nowFollowing) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(
message: nowFollowing
? 'Followed ${widget.displayName}'
: 'Unfollowed ${widget.displayName}',
centered: false,
),
),
);
}
void _showErrorSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(message: message, centered: false),
),
);
}
@override
Widget build(BuildContext context) {
if (!_controller.shouldShow) {
return const SizedBox.shrink();
}
final following = _controller.isFollowing == true;
final busy = _controller.loading || _controller.actionInProgress;
return FilledButton.tonal(
onPressed: busy ? null : _onPressed,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Icon(
following
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 16,
),
Text(following ? 'Unfollow' : 'Follow'),
],
),
);
}
}