frosty/lib/widgets/user_actions_modal.dart
copilot 30848e6fe8 Add follow and unfollow for live and offline channels.
Long-press actions use Helix for follow status and web GQL mutations via the linked session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 03:06:05 +02:00

350 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.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/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/frosty_dialog.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
class UserActionsModal extends StatefulWidget {
final AuthStore authStore;
final String name;
final String userLogin;
final String userId;
final bool showPinOption;
final bool? isPinned;
/// When set (e.g. Following tab), skip the Helix lookup and show this state.
final bool? initialIsFollowing;
/// Called after a successful follow (`true`) or unfollow (`false`).
final ValueChanged<bool>? onFollowChanged;
/// Optional moderation actions (shown at the top) for chat contexts. Kept as
/// an injected widget so this generic modal stays chat-agnostic.
final Widget? moderationActions;
const UserActionsModal({
super.key,
required this.authStore,
required this.name,
required this.userLogin,
required this.userId,
this.showPinOption = false,
this.isPinned,
this.initialIsFollowing,
this.onFollowChanged,
this.moderationActions,
});
@override
State<UserActionsModal> createState() => _UserActionsModalState();
}
class _UserActionsModalState extends State<UserActionsModal> {
bool? _isFollowing;
bool _followLoading = false;
bool _followActionInProgress = false;
bool get _isSelf =>
widget.authStore.user.details?.id != null &&
widget.authStore.user.details!.id == widget.userId;
bool get _canFollow =>
widget.authStore.isLoggedIn && !_isSelf;
@override
void initState() {
super.initState();
if (!_canFollow) return;
if (widget.initialIsFollowing != null) {
_isFollowing = widget.initialIsFollowing;
} else {
_loadFollowState();
}
}
Future<void> _loadFollowState() async {
final cached = widget.authStore.user.cachedIsFollowing(widget.userId);
if (cached != null) {
setState(() => _isFollowing = cached);
return;
}
setState(() => _followLoading = true);
try {
final following = await widget.authStore.user.isFollowingChannel(
widget.userId,
);
if (mounted) {
setState(() {
_isFollowing = following;
_followLoading = false;
});
}
} catch (_) {
if (mounted) {
setState(() => _followLoading = false);
}
}
}
Future<void> _toggleFollow() async {
if (_followActionInProgress || _isFollowing == null) return;
final gqlToken = widget.authStore.gqlToken;
if (gqlToken == null) {
await _promptLinkWebSession();
return;
}
final following = _isFollowing!;
setState(() => _followActionInProgress = true);
try {
if (following) {
await widget.authStore.user.unfollow(
targetId: widget.userId,
gqlToken: gqlToken,
);
} else {
await widget.authStore.user.follow(
targetId: widget.userId,
gqlToken: gqlToken,
);
}
if (!mounted) return;
final nowFollowing = !following;
setState(() {
_isFollowing = nowFollowing;
_followActionInProgress = false;
});
widget.onFollowChanged?.call(nowFollowing);
final messenger = ScaffoldMessenger.maybeOf(context);
Navigator.pop(context);
messenger?.showSnackBar(
SnackBar(
content: AlertMessage(
message: nowFollowing
? 'Followed ${widget.name}'
: 'Unfollowed ${widget.name}',
centered: false,
),
),
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _followActionInProgress = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(
message: e.message,
centered: false,
),
),
);
} catch (_) {
if (!mounted) return;
setState(() => _followActionInProgress = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: AlertMessage(
message: following
? 'Could not unfollow ${widget.name}'
: 'Could not follow ${widget.name}',
centered: false,
),
),
);
}
}
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'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final showFollowTile = _canFollow && (_followLoading || _isFollowing != null);
return ListView(
primary: false,
shrinkWrap: true,
children: [
?widget.moderationActions,
if (widget.showPinOption)
ListTile(
leading: const Icon(Icons.push_pin_outlined),
title: Text(
'${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}',
),
onTap: () {
if (widget.isPinned == true) {
context.read<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().pinnedChannelIds
..remove(widget.userId),
];
} else {
context.read<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().pinnedChannelIds,
widget.userId,
];
}
Navigator.pop(context);
},
),
if (showFollowTile)
ListTile(
leading: _followLoading || _followActionInProgress
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
_isFollowing == true
? Icons.heart_broken_rounded
: Icons.favorite_rounded,
),
title: Text(
_isFollowing == true
? 'Unfollow ${widget.name}'
: 'Follow ${widget.name}',
),
enabled: !_followLoading && !_followActionInProgress,
onTap: _toggleFollow,
),
if (widget.authStore.isLoggedIn)
ListTile(
leading: const Icon(Icons.block_rounded),
onTap: () => widget.authStore
.showBlockDialog(
context,
targetUser: widget.name,
targetUserId: widget.userId,
)
.then((_) {
if (context.mounted) {
Navigator.pop(context);
}
}),
title: Text('Block ${widget.name}'),
),
ListTile(
leading: const Icon(Icons.outlined_flag_rounded),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
extendBody: true,
extendBodyBehindAppBar: true,
appBar: AppBar(
centerTitle: false,
elevation: 0,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
theme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
),
leading: IconButton(
tooltip: 'Back',
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
title: Text('Report ${widget.name}'),
),
body: Stack(
children: [
// WebView content
Positioned.fill(
child: Padding(
padding: EdgeInsets.only(
top:
MediaQuery.of(context).padding.top +
kToolbarHeight,
),
child: WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(
Uri.parse(
'https://www.twitch.tv/${widget.userLogin}/report',
),
)
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (error) {
debugPrint(
'WebView error: ${error.description}',
);
},
),
),
),
),
),
// Blurred app bar overlay
Positioned(
top: 0,
left: 0,
right: 0,
child: BlurredContainer(
gradientDirection: GradientDirection.up,
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top,
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right,
),
child: const SizedBox(height: kToolbarHeight),
),
),
],
),
);
},
),
),
title: Text('Report ${widget.name}'),
),
],
);
}
}