import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:go_router/go_router.dart'; import '../../providers/server_provider.dart'; import '../../providers/server_sync_provider.dart'; import '../../providers/sm_account_provider.dart'; import '../../shared/constants/breakpoints.dart'; import '../../shared/theme/app_theme.dart'; import '../../shared/theme/app_theme_scope.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/app_dialog.dart'; import '../../shared/widgets/app_inline_alert.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/app_snack_bar.dart'; import '../../shared/widgets/page_background.dart'; import 'account_auth_page.dart'; typedef AccountFeedback = void Function(String message, {AppAlertTone tone}); Future showAccountCenterSheet(BuildContext context) { final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width); if (isCompact) return _showAccountBottomSheet(context); return _showAccountSideSheet(context); } Future _showAccountBottomSheet(BuildContext context) { return showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, useSafeArea: true, isDismissible: true, backgroundColor: Colors.transparent, barrierColor: Colors.black38, builder: (_) => const AppThemeScope(child: _AccountBottomSheet()), ); } Future _showAccountSideSheet(BuildContext context) { return showGeneralDialog( context: context, useRootNavigator: true, barrierDismissible: true, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.black38, transitionDuration: const Duration(milliseconds: 220), pageBuilder: (_, _, _) => const AppThemeScope(child: _AccountSideSheet()), transitionBuilder: (_, animation, _, child) { final curved = CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); return SlideTransition( position: Tween( begin: const Offset(1, 0), end: Offset.zero, ).animate(curved), child: child, ); }, ); } class AccountCenterPage extends ConsumerStatefulWidget { const AccountCenterPage({super.key}); @override ConsumerState createState() => _AccountCenterPageState(); } class _AccountCenterPageState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, body: Stack( children: [ const Positioned.fill(child: PageBackground()), CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ AppSliverHeader( title: '账号', leading: IconButton( icon: const Icon(Icons.arrow_back_rounded), onPressed: () => context.pop(), ), ), SliverPadding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), sliver: SliverToBoxAdapter( child: AccountCenterContent( onAuthRequested: _openAuth, onFeedback: _pageFeedback, syncConfirmInPopover: false, ), ), ), ], ), ], ), ); } Future _openAuth() async { final message = await context.push('/account/auth'); if (!mounted || message == null || message.isEmpty) return; showAppSnackBar(context, message, tone: AppSnackTone.success); } void _pageFeedback(String message, {AppAlertTone tone = AppAlertTone.info}) { showAppSnackBar( context, message, tone: switch (tone) { AppAlertTone.error => AppSnackTone.error, AppAlertTone.warning => AppSnackTone.warning, AppAlertTone.success => AppSnackTone.success, AppAlertTone.info => AppSnackTone.neutral, }, ); } } class AccountCenterContent extends ConsumerStatefulWidget { final VoidCallback onAuthRequested; final AccountFeedback onFeedback; final bool syncConfirmInPopover; const AccountCenterContent({ super.key, required this.onAuthRequested, required this.onFeedback, required this.syncConfirmInPopover, }); @override ConsumerState createState() => _AccountCenterContentState(); } class _AccountCenterContentState extends ConsumerState { bool _logoutBusy = false; @override Widget build(BuildContext context) { final account = ref.watch(smAccountProvider).value; final syncState = ref.watch(serverSyncProvider); final status = account?.status ?? SmAuthStatus.unauthenticated; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAccountSection(context, account, status), const SizedBox(height: 24), _buildSyncSection(context, account, syncState), ], ); } Widget _buildAccountSection( BuildContext context, SmAccountState? account, SmAuthStatus status, ) { return switch (status) { SmAuthStatus.authenticated => _buildLoggedIn(context, account!), SmAuthStatus.restoring => _buildRestoring(context, account!), SmAuthStatus.expired => _buildReauth( context, account!, title: '登录已过期', subtitle: '请重新登录以继续使用扩展服务', ), SmAuthStatus.degraded => _buildReauth( context, account!, title: '账号凭据不完整', subtitle: '请重新登录以继续使用扩展服务', ), SmAuthStatus.unauthenticated => _buildEntry(context), }; } Widget _buildEntry(BuildContext context) { final theme = Theme.of(context); return AppCard( onTap: widget.onAuthRequested, padding: const EdgeInsets.all(20), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: theme.colorScheme.onSurface.withValues(alpha: 0.08), shape: BoxShape.circle, ), child: Icon( Icons.person_outline_rounded, size: 24, color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '登录 Player 账号', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, fontSize: 16, ), ), const SizedBox(height: 3), Text( '登录以使用云同步和 TMDB 等扩展服务', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.58), ), ), ], ), ), const Icon(Icons.chevron_right_rounded), ], ), ); } Widget _buildRestoring(BuildContext context, SmAccountState s) { final theme = Theme.of(context); return AppCard( padding: const EdgeInsets.all(20), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), child: Icon( Icons.person_rounded, size: 24, color: theme.colorScheme.primary, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( s.email.isEmpty ? 'Player 账号' : s.email, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, fontSize: 16, ), ), const SizedBox(height: 3), Text( '正在恢复登录…', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.58), ), ), ], ), ), const AppLoadingRing(size: 18), ], ), ); } Widget _buildReauth( BuildContext context, SmAccountState s, { required String title, required String subtitle, }) { final theme = Theme.of(context); return AppCard( onTap: widget.onAuthRequested, padding: const EdgeInsets.all(20), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: const Color(0xFFFAAD14).withValues(alpha: 0.15), shape: BoxShape.circle, ), child: const Icon( Icons.error_outline, size: 24, color: Color(0xFFFAAD14), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, fontSize: 16, ), ), const SizedBox(height: 3), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.58), ), ), ], ), ), const Icon(Icons.chevron_right_rounded), ], ), ); } Widget _buildLoggedIn(BuildContext context, SmAccountState s) { final theme = Theme.of(context); return AppCard( padding: const EdgeInsets.all(20), child: Column( children: [ Row( children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: s.isVip ? const Color(0xFFFAAD14).withValues(alpha: 0.15) : theme.colorScheme.primary.withValues(alpha: 0.12), shape: BoxShape.circle, border: s.isVip ? Border.all( color: const Color(0xFFFAAD14).withValues(alpha: 0.4), width: 1.5, ) : null, ), child: Icon( s.isVip ? Icons.workspace_premium_rounded : Icons.person_rounded, size: 28, color: s.isVip ? const Color(0xFFFAAD14) : theme.colorScheme.primary, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( s.email.isNotEmpty ? s.email : 'Player 账号', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, fontSize: 17, ), ), const SizedBox(height: 4), Text( _vipLabel(s), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues( alpha: 0.58, ), ), ), ], ), ), if (s.isVip) Container( margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: const Color(0xFFFAAD14), borderRadius: BorderRadius.circular(4), ), child: const Text( 'VIP', style: TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700, ), ), ), ], ), const Divider(height: 32), InkWell( onTap: _logoutBusy ? null : _logout, child: Row( children: [ Icon( Icons.logout, size: 18, color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), const SizedBox(width: 12), Expanded( child: Text( '退出登录', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), ), if (_logoutBusy) const AppLoadingRing(size: 18), ], ), ), ], ), ); } Widget _buildSyncSection( BuildContext context, SmAccountState? account, ServerSyncState syncState, ) { final theme = Theme.of(context); final hasVip = account?.hasVip ?? false; final busy = syncState.busy; final localCount = ref.watch(serverListProvider).value?.length ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), child: Text( '云同步', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.55), fontWeight: FontWeight.w600, ), ), ), AppCard( padding: EdgeInsets.zero, child: Column( children: [ _buildSyncStatus(context, syncState, hasVip), _divider(context), _SyncActionRow( icon: Icons.cloud_upload_outlined, label: '上传到云端', enabled: !busy && hasVip, busy: busy, usePopover: widget.syncConfirmInPopover, confirmTitle: '上传到云端?', confirmMessage: '这将把本地 $localCount 个服务器及对应登录态整包上传到云端,覆盖云端已有备份。', confirmLabel: '确认上传', onConfirm: () => _cloudUpload(localCount), ), _divider(context), _SyncActionRow( icon: Icons.cloud_download_outlined, label: '从云端拉取', enabled: !busy && hasVip, busy: busy, usePopover: widget.syncConfirmInPopover, destructive: true, confirmTitle: '从云端拉取?', confirmMessage: '这将用云端备份整包替换本地当前的 $localCount 个服务器及对应登录态;弹幕源按 URL 合并(云端为权威)。本地未上传到云端的改动会丢失。', confirmLabel: '替换并拉取', onConfirm: () => _cloudPull(localCount), ), ], ), ), if (!hasVip) Padding( padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), child: Text( '云同步需要 VIP 会员', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.45), ), ), ), ], ); } Widget _buildSyncStatus( BuildContext context, ServerSyncState syncState, bool hasVip, ) { final theme = Theme.of(context); String statusText; Color? statusColor; if (syncState.lastSyncError != null) { statusText = '同步失败'; statusColor = const Color(0xFFEF4444); } else if (syncState.lastSyncAt != null) { statusText = '上次同步:${_formatTime(syncState.lastSyncAt!)}'; statusColor = null; } else if (!hasVip) { statusText = '未启用'; statusColor = null; } else { statusText = '未同步'; statusColor = null; } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), child: Row( children: [ Icon( syncState.lastSyncError != null ? Icons.error_outline : Icons.cloud_done_outlined, size: 20, color: statusColor ?? theme.colorScheme.onSurface.withValues(alpha: 0.5), ), const SizedBox(width: 14), Text( statusText, style: theme.textTheme.bodyMedium?.copyWith( color: statusColor ?? theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], ), ); } Widget _divider(BuildContext context) { return Divider( height: 1, indent: 50, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), ); } String _vipLabel(SmAccountState s) { if (s.vipPermanent) return '永久 VIP'; if (s.isVip && s.vipExpiresAt != null) { final d = DateTime.fromMillisecondsSinceEpoch(s.vipExpiresAt!); String two(int v) => v.toString().padLeft(2, '0'); return 'VIP 有效期至 ${d.year}-${two(d.month)}-${two(d.day)}'; } return '已登录'; } String _formatTime(DateTime t) { String two(int v) => v.toString().padLeft(2, '0'); final now = DateTime.now(); if (t.year == now.year && t.month == now.month && t.day == now.day) { return '今天 ${two(t.hour)}:${two(t.minute)}'; } return '${t.month}/${t.day} ${two(t.hour)}:${two(t.minute)}'; } Future _logout() async { setState(() => _logoutBusy = true); await ref.read(smAccountProvider.notifier).logout(); if (!mounted) return; setState(() => _logoutBusy = false); widget.onFeedback('已退出登录', tone: AppAlertTone.success); } Future _cloudUpload(int localCount) async { if (!widget.syncConfirmInPopover) { final confirmed = await showAppConfirmDialog( context, title: '上传到云端', body: Text('这将把本地 $localCount 个服务器及对应登录态整包上传到云端,覆盖云端已有的备份。确定继续?'), confirmLabel: '上传', ); if (!confirmed || !mounted) return; } final outcome = await ref.read(serverSyncProvider.notifier).upload(); if (!mounted) return; widget.onFeedback( outcome.message, tone: outcome.isError ? AppAlertTone.error : AppAlertTone.info, ); } Future _cloudPull(int localCount) async { if (!widget.syncConfirmInPopover) { final confirmed = await showAppConfirmDialog( context, title: '从云端拉取', body: Text( '这将用云端备份整包替换本地当前的 $localCount 个服务器及对应登录态;' '弹幕源按 URL 合并(云端为权威)。' '本地未上传到云端的改动会丢失。确定继续?', ), confirmLabel: '替换并拉取', destructive: true, ); if (!confirmed || !mounted) return; } final outcome = await ref.read(serverSyncProvider.notifier).pull(); if (!mounted) return; widget.onFeedback( outcome.message, tone: outcome.isError ? AppAlertTone.error : AppAlertTone.info, ); } } class _SyncActionRow extends StatelessWidget { final IconData icon; final String label; final bool enabled; final bool busy; final bool usePopover; final String confirmTitle; final String confirmMessage; final String confirmLabel; final bool destructive; final Future Function() onConfirm; const _SyncActionRow({ required this.icon, required this.label, required this.enabled, required this.busy, required this.usePopover, required this.confirmTitle, required this.confirmMessage, required this.confirmLabel, required this.onConfirm, this.destructive = false, }); @override Widget build(BuildContext context) { final child = _SyncActionRowContent( icon: icon, label: label, enabled: enabled, busy: busy, ); if (!usePopover) { return InkWell( onTap: enabled ? () => unawaited(onConfirm()) : null, child: child, ); } return FPopover( constraints: const FPortalConstraints(maxWidth: 300), popoverAnchor: Alignment.topRight, childAnchor: Alignment.bottomRight, popoverBuilder: (_, controller) => _SyncConfirmBubble( title: confirmTitle, message: confirmMessage, confirmLabel: confirmLabel, destructive: destructive, onCancel: () => unawaited(controller.hide()), onConfirm: () { unawaited(controller.hide()); unawaited(onConfirm()); }, ), builder: (_, controller, child) => InkWell(onTap: enabled ? controller.toggle : null, child: child), child: child, ); } } class _SyncActionRowContent extends StatelessWidget { final IconData icon; final String label; final bool enabled; final bool busy; const _SyncActionRowContent({ required this.icon, required this.label, required this.enabled, required this.busy, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), child: Row( children: [ Icon( icon, size: 20, color: enabled ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.3), ), const SizedBox(width: 14), Expanded( child: Text( label, style: theme.textTheme.bodyMedium?.copyWith( color: enabled ? null : theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ), ), if (busy) const AppLoadingRing(size: 18), ], ), ); } } class _SyncConfirmBubble extends StatelessWidget { final String title; final String message; final String confirmLabel; final bool destructive; final VoidCallback onCancel; final VoidCallback onConfirm; const _SyncConfirmBubble({ required this.title, required this.message, required this.confirmLabel, required this.destructive, required this.onCancel, required this.onConfirm, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; return SizedBox( width: 280, child: Padding( padding: const EdgeInsets.all(14), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), Text( message, style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, height: 1.35, ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FButton( onPress: onCancel, variant: FButtonVariant.outline, mainAxisSize: MainAxisSize.min, child: const Text('取消'), ), const SizedBox(width: 8), FButton( onPress: onConfirm, variant: destructive ? FButtonVariant.destructive : FButtonVariant.primary, mainAxisSize: MainAxisSize.min, child: Text(confirmLabel), ), ], ), ], ), ), ); } } class _AccountSideSheet extends StatelessWidget { const _AccountSideSheet(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final screenWidth = MediaQuery.sizeOf(context).width; final width = math.min( math.min(480.0, math.max(400.0, screenWidth * 0.32)), screenWidth * 0.92, ); return Align( alignment: Alignment.centerRight, child: SizedBox( width: width, height: double.infinity, child: Material( color: theme.colorScheme.surface, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.horizontal( left: Radius.circular(24), ), child: const _AccountSheetFrame(isSideSheet: true), ), ), ); } } class _AccountBottomSheet extends StatelessWidget { const _AccountBottomSheet(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final size = MediaQuery.sizeOf(context); final viewInsets = MediaQuery.viewInsetsOf(context); final sheet = Material( color: theme.colorScheme.surface, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: const SafeArea( top: false, child: _AccountSheetFrame(isSideSheet: false), ), ); return AnimatedPadding( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, padding: EdgeInsets.only(bottom: viewInsets.bottom), child: Align( alignment: Alignment.bottomCenter, child: ConstrainedBox( constraints: BoxConstraints(maxHeight: size.height * 0.92), child: sheet, ), ), ); } } class _AccountSheetFrame extends StatefulWidget { final bool isSideSheet; const _AccountSheetFrame({required this.isSideSheet}); @override State<_AccountSheetFrame> createState() => _AccountSheetFrameState(); } class _AccountSheetFrameState extends State<_AccountSheetFrame> { bool _authMode = false; @override Widget build(BuildContext context) { Widget body = AnimatedSwitcher( duration: const Duration(milliseconds: 180), layoutBuilder: (currentChild, previousChildren) => Stack( alignment: Alignment.topCenter, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ), child: _authMode ? _authBody(context) : _overviewBody(context), ); body = widget.isSideSheet ? Expanded(child: body) : Flexible(fit: FlexFit.loose, child: body); return Column( mainAxisSize: widget.isSideSheet ? MainAxisSize.max : MainAxisSize.min, children: [ if (widget.isSideSheet) SizedBox(height: MediaQuery.paddingOf(context).top), _AccountSheetTitleBar( showBack: _authMode, isSideSheet: widget.isSideSheet, onBack: () => setState(() => _authMode = false), onClose: () => Navigator.of(context).pop(), ), Divider( height: 1, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.08), ), body, ], ); } Widget _overviewBody(BuildContext context) { return SingleChildScrollView( key: const ValueKey('overview'), physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: AccountCenterContent( onAuthRequested: () => setState(() => _authMode = true), onFeedback: (message, {tone = AppAlertTone.info}) { showAppToast(context, message, tone: tone); }, syncConfirmInPopover: true, ), ); } Widget _authBody(BuildContext context) { return SingleChildScrollView( key: const ValueKey('auth'), physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: AccountAuthForm( onSuccess: (message) => Navigator.of(context).pop(message), ), ), ), ); } } class _AccountSheetTitleBar extends StatelessWidget { final bool showBack; final bool isSideSheet; final VoidCallback onBack; final VoidCallback onClose; const _AccountSheetTitleBar({ required this.showBack, required this.isSideSheet, required this.onBack, required this.onClose, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final reservedTrailing = isSideSheet ? MediaQuery.paddingOf(context).right : 0.0; return SizedBox( height: 56, child: Stack( alignment: Alignment.center, children: [ Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(left: 8), child: showBack ? IconButton( onPressed: onBack, icon: const Icon(Icons.arrow_back_rounded), tooltip: '返回', ) : const SizedBox(width: 48), ), ), Text( '账号', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), Align( alignment: Alignment.centerRight, child: Padding( padding: EdgeInsets.only(right: 8 + reservedTrailing), child: IconButton( onPressed: onClose, icon: const Icon(Icons.close_rounded), tooltip: '关闭', ), ), ), ], ), ); } }