Files
CloudHome-Ops/lib/screens/profile/settings_page.dart

431 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/locale_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/display_provider.dart';
import '../../services/api_client.dart';
import '../../services/sys_service.dart';
import '../../utils/constants.dart';
import '../../utils/i10n.dart';
import '../../utils/app_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// 设置页(底部导航第 3 个 tab):主题切换 / 语言占位 / 关于 / 退出登录。
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
Future<void> _showChangePasswordDialog(BuildContext context, WidgetRef ref) async {
final t = AppLocalizations.of(context);
final oldCtrl = TextEditingController();
final newCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
bool saving = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => AlertDialog(
title: Text(t.passwordDialogTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: oldCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.oldPassword,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: newCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.newPassword,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: confirmCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: t.confirmNewPassword,
border: const OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: saving ? null : () => Navigator.pop(ctx),
child: Text(t.cancel),
),
FilledButton(
onPressed: saving
? null
: () async {
final oldPwd = oldCtrl.text.trim();
final newPwd = newCtrl.text.trim();
final confirmPwd = confirmCtrl.text.trim();
if (oldPwd.isEmpty || newPwd.isEmpty) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.pleaseFillPassword)),
);
return;
}
if (newPwd != confirmPwd) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.passwordsDoNotMatch)),
);
return;
}
setState(() => saving = true);
try {
await SystemService().updatePassword(oldPwd, newPwd);
if (!ctx.mounted) return;
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.passwordChangeSuccess)),
);
// 修改成功后安全登出,要求用新密码重新登录。
Future.delayed(const Duration(seconds: 1), () {
if (context.mounted) {
ref.read(authProvider.notifier).logout();
}
});
} on ApiException catch (e) {
setState(() => saving = false);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e.message)),
);
} catch (e) {
setState(() => saving = false);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.passwordChangeFailed(formatApiError(e)))),
);
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.save),
),
],
),
),
);
oldCtrl.dispose();
newCtrl.dispose();
confirmCtrl.dispose();
}
String _themeModeLabel(ThemeMode mode, AppLocalizations t) {
switch (mode) {
case ThemeMode.dark:
return t.themeDark;
case ThemeMode.light:
return t.themeLight;
case ThemeMode.system:
return t.themeSystem;
}
}
String _orientationLabel(DisplayOrientation mode, AppLocalizations t) {
switch (mode) {
case DisplayOrientation.system:
return t.orientationSystem;
case DisplayOrientation.portrait:
return t.orientationPortrait;
case DisplayOrientation.landscape:
return t.orientationLandscape;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
final displayOrientation = ref.watch(displayOrientationProvider);
final authState = ref.watch(authProvider);
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.settings)),
body: ListView(
children: [
if (authState.user != null)
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(authState.user!.displayName),
subtitle: Text(
authState.user!.role.isEmpty
? '${t.account}:${authState.user!.account}'
: authState.user!.role,
),
),
const Divider(),
// 主题切换:深色 / 浅色 / 跟随系统
_AppSettingsTile(
leading: const Icon(Icons.dark_mode_outlined),
title: t.themeMode,
subtitle: _themeModeLabel(themeMode, t),
control: SegmentedButton<ThemeMode>(
segments: [
ButtonSegment(
value: ThemeMode.dark,
icon: const Icon(Icons.dark_mode),
label: Text(t.themeDark),
),
ButtonSegment(
value: ThemeMode.light,
icon: const Icon(Icons.light_mode),
label: Text(t.themeLight),
),
ButtonSegment(
value: ThemeMode.system,
icon: const Icon(Icons.settings_suggest),
label: Text(t.themeSystem),
),
],
selected: {themeMode},
onSelectionChanged: (selection) => ref
.read(themeModeProvider.notifier)
.setThemeMode(selection.first),
),
),
// 语言切换:简体中文 / English
_AppSettingsTile(
leading: const Icon(Icons.language_outlined),
title: t.language,
subtitle: appLocale.label,
control: SegmentedButton<AppLocale>(
segments: const [
ButtonSegment(value: AppLocale.zh, label: Text('中文')),
ButtonSegment(value: AppLocale.en, label: Text('EN')),
],
selected: {appLocale},
onSelectionChanged: (selection) {
try {
ref.read(localeProvider.notifier).setLocale(selection.first);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('语言切换失败:$e')),
);
}
},
),
),
const Divider(),
// 显示方向:跟随系统 / 竖屏 / 横屏(手动切换屏幕方向,布局自动适配)。
_AppSettingsTile(
leading: const Icon(Icons.screen_rotation_outlined),
title: t.orientationMode,
subtitle: _orientationLabel(displayOrientation, t),
control: SegmentedButton<DisplayOrientation>(
segments: [
ButtonSegment(
value: DisplayOrientation.system,
icon: const Icon(Icons.screen_rotation),
label: Text(t.orientationSystem),
),
ButtonSegment(
value: DisplayOrientation.portrait,
icon: const Icon(Icons.stay_current_portrait),
label: Text(t.orientationPortrait),
),
ButtonSegment(
value: DisplayOrientation.landscape,
icon: const Icon(Icons.stay_current_landscape),
label: Text(t.orientationLandscape),
),
],
selected: {displayOrientation},
onSelectionChanged: (selection) => ref
.read(displayOrientationProvider.notifier)
.set(selection.first),
),
),
const Divider(),
// 修改密码
ListTile(
leading: const Icon(Icons.lock_outline),
title: Text(t.changePassword),
onTap: () => _showChangePasswordDialog(context, ref),
),
const Divider(),
// 检查更新
ListTile(
leading: const Icon(Icons.system_update_alt_outlined),
title: Text(t.checkUpdate),
subtitle: _VersionSubtitle(t: t),
onTap: () => checkAndPromptUpdate(
context,
silentIfNoUpdate: false,
orgId: ref.read(authProvider).user?.orgId,
),
),
const Divider(),
// 关于
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(t.about),
subtitle: _VersionSubtitle(t: t, includeName: true),
onTap: () async {
final pkg = await currentPackageInfo;
if (!context.mounted) return;
showAboutDialog(
context: context,
applicationName: AppConstants.appName,
applicationVersion: '${pkg.version} (${pkg.buildNumber})',
applicationIcon: Image.asset(
'assets/images/logo.png',
width: 64,
height: 64,
),
applicationLegalese: 'Device/system operation admin mobile demo',
);
},
),
const Divider(),
// 退出登录
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.error,
),
title: Text(
t.logout,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.logout),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.logout),
),
],
),
);
if (confirmed == true && context.mounted) {
ref.read(authProvider.notifier).logout();
}
},
),
],
),
);
}
}
/// 设置项:图标 + 标题 + 副标题在上,控件整行铺满在下。
///
/// 用于 [SegmentedButton] 等较宽的控件,避免塞进 [ListTile.trailing]
/// 在窄屏溢出。
class _AppSettingsTile extends StatelessWidget {
const _AppSettingsTile({
required this.title,
required this.control,
this.subtitle,
this.leading,
});
final String title;
final Widget control;
final String? subtitle;
final Widget? leading;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Padding(
padding: EdgeInsets.only(left: leading != null ? 32 : 0),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 12),
SizedBox(width: double.infinity, child: control),
],
),
);
}
}
/// 当前 App 版本副标题:异步读取 [PackageInfo] 展示版本名与构建号。
///
/// [includeName]=true 时前缀应用名(用于「关于」项)。
class _VersionSubtitle extends StatelessWidget {
const _VersionSubtitle({required this.t, this.includeName = false});
final AppLocalizations t;
final bool includeName;
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: currentPackageInfo,
builder: (ctx, snap) {
final info = snap.data;
if (includeName) {
// 「关于」项:副标题只展示应用名,版本详情交由「关于」弹窗承载,
// 避免与应用名 + 版本号 + 构建号叠加成冗余且暴露内部构建号的文案。
return const Text(AppConstants.appName);
}
// 「检查更新」项:展示当前已安装版本名(不暴露内部构建号)。
final version = info != null ? info.version : t.loading;
return Text('${t.currentVersion} $version');
},
);
}
}