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>
206 lines
5.8 KiB
Dart
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'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|