Long-press actions use Helix for follow status and web GQL mutations via the linked session. Co-authored-by: Cursor <cursoragent@cursor.com>
350 lines
11 KiB
Dart
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}'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|