1046 lines
31 KiB
Dart
1046 lines
31 KiB
Dart
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<String?> showAccountCenterSheet(BuildContext context) {
|
|
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
|
if (isCompact) return _showAccountBottomSheet(context);
|
|
return _showAccountSideSheet(context);
|
|
}
|
|
|
|
Future<String?> _showAccountBottomSheet(BuildContext context) {
|
|
return showModalBottomSheet<String>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
isDismissible: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: Colors.black38,
|
|
builder: (_) => const AppThemeScope(child: _AccountBottomSheet()),
|
|
);
|
|
}
|
|
|
|
Future<String?> _showAccountSideSheet(BuildContext context) {
|
|
return showGeneralDialog<String>(
|
|
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<Offset>(
|
|
begin: const Offset(1, 0),
|
|
end: Offset.zero,
|
|
).animate(curved),
|
|
child: child,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
class AccountCenterPage extends ConsumerStatefulWidget {
|
|
const AccountCenterPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<AccountCenterPage> createState() => _AccountCenterPageState();
|
|
}
|
|
|
|
class _AccountCenterPageState extends ConsumerState<AccountCenterPage> {
|
|
@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<void> _openAuth() async {
|
|
final message = await context.push<String>('/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<AccountCenterContent> createState() =>
|
|
_AccountCenterContentState();
|
|
}
|
|
|
|
class _AccountCenterContentState extends ConsumerState<AccountCenterContent> {
|
|
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<void> _logout() async {
|
|
setState(() => _logoutBusy = true);
|
|
await ref.read(smAccountProvider.notifier).logout();
|
|
if (!mounted) return;
|
|
setState(() => _logoutBusy = false);
|
|
widget.onFeedback('已退出登录', tone: AppAlertTone.success);
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> 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: '关闭',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|