402 lines
13 KiB
Dart
402 lines
13 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:go_router/go_router.dart';
|
||
|
|
|
||
|
|
import '../../providers/session_provider.dart';
|
||
|
|
import '../../providers/sm_account_provider.dart';
|
||
|
|
import '../../providers/update_provider.dart';
|
||
|
|
import '../../shared/app_info.dart';
|
||
|
|
import '../../shared/theme/app_theme.dart';
|
||
|
|
import '../../shared/widgets/app_card.dart';
|
||
|
|
import '../../shared/widgets/app_loading_ring.dart';
|
||
|
|
import '../../shared/widgets/app_inline_alert.dart';
|
||
|
|
import '../../shared/widgets/app_sliver_header.dart';
|
||
|
|
import '../../shared/widgets/page_background.dart';
|
||
|
|
import '../../shared/widgets/server_avatar.dart';
|
||
|
|
import '../modules/modules_home_page.dart';
|
||
|
|
import '../settings/settings_page.dart';
|
||
|
|
|
||
|
|
class ProfilePage extends ConsumerWidget {
|
||
|
|
const ProfilePage({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
||
|
|
final activeSession = ref.watch(activeSessionProvider);
|
||
|
|
final account = ref.watch(smAccountProvider).value;
|
||
|
|
final appVersion =
|
||
|
|
ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback;
|
||
|
|
final updateState = ref.watch(updateProvider);
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
backgroundColor: Colors.transparent,
|
||
|
|
extendBodyBehindAppBar: true,
|
||
|
|
body: Stack(
|
||
|
|
children: [
|
||
|
|
const Positioned.fill(child: PageBackground()),
|
||
|
|
CustomScrollView(
|
||
|
|
physics: const AlwaysScrollableScrollPhysics(
|
||
|
|
parent: BouncingScrollPhysics(),
|
||
|
|
),
|
||
|
|
slivers: [
|
||
|
|
const AppSliverHeader(title: '我的'),
|
||
|
|
SliverPadding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||
|
|
sliver: SliverList(
|
||
|
|
delegate: SliverChildListDelegate.fixed([
|
||
|
|
_SearchEntry(onTap: () => context.push('/global-search')),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_ServerAccountCard(
|
||
|
|
serverName: activeSession?.serverName ?? '未连接服务器',
|
||
|
|
userName: activeSession?.userName ?? '添加或登录 Emby 服务器',
|
||
|
|
iconUrl: '',
|
||
|
|
active: activeSession != null,
|
||
|
|
onTap: () => context.go('/servers'),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
_SmAccountCard(
|
||
|
|
account: account,
|
||
|
|
onTap: () => context.push('/account-center'),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
const _SectionTitle('快捷入口'),
|
||
|
|
_MenuCard(
|
||
|
|
children: [
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.history_rounded,
|
||
|
|
title: '观看历史',
|
||
|
|
subtitle: '跨服务器继续观看',
|
||
|
|
onTap: () => context.go('/history'),
|
||
|
|
),
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.favorite_outline_rounded,
|
||
|
|
title: '我的收藏',
|
||
|
|
subtitle: '查看所有服务器收藏',
|
||
|
|
onTap: () => context.go('/favorites-all'),
|
||
|
|
),
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.new_releases_outlined,
|
||
|
|
title: '最近添加',
|
||
|
|
subtitle: '按服务器聚合最新内容',
|
||
|
|
onTap: () => context.go('/recent'),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 18),
|
||
|
|
const _SectionTitle('应用'),
|
||
|
|
_MenuCard(
|
||
|
|
children: [
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.extension_rounded,
|
||
|
|
title: '模块中心',
|
||
|
|
subtitle: '导入、配置与浏览 ForwardWidgets 模块',
|
||
|
|
onTap: () => showModuleCenterSheet(context),
|
||
|
|
),
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.settings_outlined,
|
||
|
|
title: '设置',
|
||
|
|
subtitle: '播放、账号与外观',
|
||
|
|
onTap: () => showSettingsDialog(context),
|
||
|
|
),
|
||
|
|
_ProfileMenuItem(
|
||
|
|
icon: Icons.update_outlined,
|
||
|
|
title: '检查更新',
|
||
|
|
subtitle: _updateSubtitle(updateState, appVersion),
|
||
|
|
trailing: updateState.status == UpdateStatus.checking
|
||
|
|
? const AppLoadingRing(size: 18)
|
||
|
|
: null,
|
||
|
|
onTap:
|
||
|
|
updateState.status == UpdateStatus.checking ||
|
||
|
|
updateState.status ==
|
||
|
|
UpdateStatus.downloading ||
|
||
|
|
updateState.status ==
|
||
|
|
UpdateStatus.readyToInstall
|
||
|
|
? null
|
||
|
|
: () => ref
|
||
|
|
.read(updateProvider.notifier)
|
||
|
|
.manualCheck(),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
if (updateState.status == UpdateStatus.error) ...[
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
AppInlineAlert(
|
||
|
|
message: updateState.error ?? '检查更新失败',
|
||
|
|
tone: AppAlertTone.error,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
]),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
String _updateSubtitle(UpdateState state, String appVersion) {
|
||
|
|
return switch (state.status) {
|
||
|
|
UpdateStatus.checking => '当前版本 $appVersion,正在检查',
|
||
|
|
UpdateStatus.upToDate => '当前版本 $appVersion,已是最新版本',
|
||
|
|
UpdateStatus.available => '发现新版本 ${state.info?.version ?? ''}',
|
||
|
|
UpdateStatus.downloading => '正在下载更新',
|
||
|
|
UpdateStatus.readyToInstall => '正在打开安装程序',
|
||
|
|
UpdateStatus.error => state.error ?? '检查失败',
|
||
|
|
UpdateStatus.idle => '当前版本 $appVersion',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SearchEntry extends StatelessWidget {
|
||
|
|
final VoidCallback onTap;
|
||
|
|
|
||
|
|
const _SearchEntry({required this.onTap});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return AppCard(
|
||
|
|
onTap: onTap,
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(Icons.search_rounded, color: theme.colorScheme.primary),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Text(
|
||
|
|
'搜索电影、剧集、演员',
|
||
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.62),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const Icon(Icons.chevron_right_rounded, size: 20),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ServerAccountCard extends StatelessWidget {
|
||
|
|
final String serverName;
|
||
|
|
final String userName;
|
||
|
|
final String iconUrl;
|
||
|
|
final bool active;
|
||
|
|
final VoidCallback onTap;
|
||
|
|
|
||
|
|
const _ServerAccountCard({
|
||
|
|
required this.serverName,
|
||
|
|
required this.userName,
|
||
|
|
required this.iconUrl,
|
||
|
|
required this.active,
|
||
|
|
required this.onTap,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return AppCard(
|
||
|
|
onTap: onTap,
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
ServerAvatar(
|
||
|
|
name: serverName,
|
||
|
|
iconUrl: iconUrl,
|
||
|
|
size: 52,
|
||
|
|
isActive: active,
|
||
|
|
showActiveBorder: true,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 14),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
serverName,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 3),
|
||
|
|
Text(
|
||
|
|
userName,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const Icon(Icons.chevron_right_rounded),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SmAccountCard extends StatelessWidget {
|
||
|
|
final SmAccountState? account;
|
||
|
|
final VoidCallback onTap;
|
||
|
|
|
||
|
|
const _SmAccountCard({required this.account, required this.onTap});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
final status = account?.status ?? SmAuthStatus.unauthenticated;
|
||
|
|
final loggedIn = status == SmAuthStatus.authenticated;
|
||
|
|
final title = loggedIn ? 'player 账号' : '登录 player 账号';
|
||
|
|
final subtitle = loggedIn
|
||
|
|
? (account?.email.isNotEmpty == true ? account!.email : '已登录')
|
||
|
|
: '用于 TMDB、Trakt 等扩展服务';
|
||
|
|
|
||
|
|
return AppCard(
|
||
|
|
onTap: onTap,
|
||
|
|
padding: const EdgeInsets.all(16),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 42,
|
||
|
|
height: 42,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||
|
|
borderRadius: BorderRadius.circular(14),
|
||
|
|
),
|
||
|
|
child: Icon(
|
||
|
|
loggedIn ? Icons.verified_user_outlined : Icons.person_add_alt_1,
|
||
|
|
color: theme.colorScheme.primary,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(title, style: theme.textTheme.titleSmall),
|
||
|
|
const SizedBox(height: 3),
|
||
|
|
Text(
|
||
|
|
subtitle,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const Icon(Icons.chevron_right_rounded),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _SectionTitle extends StatelessWidget {
|
||
|
|
final String text;
|
||
|
|
|
||
|
|
const _SectionTitle(this.text);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||
|
|
child: Text(
|
||
|
|
text,
|
||
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||
|
|
color: context.appTokens.mutedForeground,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _MenuCard extends StatelessWidget {
|
||
|
|
final List<Widget> children;
|
||
|
|
|
||
|
|
const _MenuCard({required this.children});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return AppCard(
|
||
|
|
padding: EdgeInsets.zero,
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
for (var i = 0; i < children.length; i++) ...[
|
||
|
|
if (i > 0)
|
||
|
|
Divider(
|
||
|
|
height: 1,
|
||
|
|
indent: 56,
|
||
|
|
color: Theme.of(
|
||
|
|
context,
|
||
|
|
).colorScheme.onSurface.withValues(alpha: 0.08),
|
||
|
|
),
|
||
|
|
children[i],
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ProfileMenuItem extends StatelessWidget {
|
||
|
|
final IconData icon;
|
||
|
|
final String title;
|
||
|
|
final String? subtitle;
|
||
|
|
final Widget? trailing;
|
||
|
|
final VoidCallback? onTap;
|
||
|
|
|
||
|
|
const _ProfileMenuItem({
|
||
|
|
required this.icon,
|
||
|
|
required this.title,
|
||
|
|
this.subtitle,
|
||
|
|
this.trailing,
|
||
|
|
this.onTap,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return InkWell(
|
||
|
|
onTap: onTap,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Icon(icon, size: 22, color: theme.colorScheme.primary),
|
||
|
|
const SizedBox(width: 16),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(title, style: theme.textTheme.bodyMedium),
|
||
|
|
if (subtitle != null) ...[
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(
|
||
|
|
subtitle!,
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurface.withValues(
|
||
|
|
alpha: 0.54,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
trailing ?? const Icon(Icons.chevron_right_rounded, size: 20),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|