从 im-client-ios-swift-demo 搬运 Settings 逻辑,对齐 Gitea issue #5–#13 ## 基础设施 - AuthNotifier 新增 currentUid 字段,login() 接受 uid 参数 (#5) - LoginViewModel 登录成功后传入 user.uid - ApiPaths 补充 account/block/store 系列路径 - Tab 重命名"设置"→"我的",icon 改为 person_outline (#5) - AppRouteName 新增5条子路由 (edit-profile/blocklist/language/network-diagnostics/about) - app_router + auth_guard 同步注册新路由 ## Settings Feature - SettingsViewModel 重写为 NotifierProvider(去除 @riverpod 依赖) - build() 自动触发 loadProfile() - logout() 完整流程:API → WS 断开 → DB 关闭 → AuthNotifier - 6 个 navigateTo* 方法 - SettingsPage 完整 UI:资料卡 / 偏好设置 / 工具 / 关于 / 退出登录按钮 (#5 #7) - FetchProfileUseCase: GET /app/api/user/profile (#5) - LogoutUseCase: logout + disconnect + closeDatabase (#7) - UpdateProfileUseCase + UpdateProfileRequest: POST /app/api/user/update-profile (#6) - EditProfilePage + EditProfileViewModel: 昵称/bio 编辑 (#6) - LanguagePage: 语言选择 UI 框架,l10n_sdk 待接入 (#9) - BlocklistPage: 黑名单框架,API 待实现 (#10) - NetworkDiagnosticsPage + ViewModel: 四步诊断(连通/TCP/DNS/HTTPS)(#12) - AboutPage: 版本号 + 服务条款/隐私政策入口 (#13) - settings_providers.dart: 扩展 DI 装配 ## 文档 - Doc/mine_tab_architecture.md: 架构说明、数据流、路由、待完成事项 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
365 lines
12 KiB
Dart
365 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
|
|
|
/// 我的页面(原设置页)
|
|
///
|
|
/// 结构:
|
|
/// ┌─ 个人资料卡 ──────────────────────────────────────────┐
|
|
/// │ 头像 昵称 │
|
|
/// │ 手机号(掩码) UID: xxx │
|
|
/// └──────────────────────────────────────────────────────┘
|
|
/// 偏好设置 → 主题 / 语言 / 通知
|
|
/// 工具 → 黑名单 / 网络诊断
|
|
/// 关于 → 关于本应用
|
|
/// [退出登录]
|
|
class SettingsPage extends ConsumerWidget {
|
|
const SettingsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final state = ref.watch(settingsViewModelProvider);
|
|
final vm = ref.read(settingsViewModelProvider.notifier);
|
|
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
SliverAppBar.large(
|
|
title: const Text('我的'),
|
|
automaticallyImplyLeading: false,
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: _ProfileCard(state: state, onTap: () => vm.navigateToEditProfile(context)),
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionLabel('偏好设置'),
|
|
_SettingsCard(
|
|
items: [
|
|
_RowConfig(
|
|
icon: Icons.palette_outlined,
|
|
title: '主题',
|
|
onTap: () => vm.navigateToTheme(context),
|
|
),
|
|
_RowConfig(
|
|
icon: Icons.language,
|
|
title: '语言',
|
|
onTap: () => vm.navigateToLanguage(context),
|
|
),
|
|
_RowConfig(
|
|
icon: Icons.notifications_outlined,
|
|
title: '通知',
|
|
onTap: () {}, // TODO: 通知设置页
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_SectionLabel('工具'),
|
|
_SettingsCard(
|
|
items: [
|
|
_RowConfig(
|
|
icon: Icons.folder_outlined,
|
|
title: '聊天文件夹',
|
|
onTap: () {}, // TODO: Issue #11
|
|
),
|
|
_RowConfig(
|
|
icon: Icons.block,
|
|
title: '黑名单',
|
|
onTap: () => vm.navigateToBlocklist(context),
|
|
),
|
|
_RowConfig(
|
|
icon: Icons.network_check,
|
|
title: '网络诊断',
|
|
onTap: () => vm.navigateToNetworkDiagnostics(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_SectionLabel('关于'),
|
|
_SettingsCard(
|
|
items: [
|
|
_RowConfig(
|
|
icon: Icons.info_outline,
|
|
title: '关于本应用',
|
|
onTap: () => vm.navigateToAbout(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
_LogoutButton(
|
|
isLoading: state.isLoggingOut,
|
|
onTap: () => _confirmLogout(context, ref),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('退出登录'),
|
|
content: const Text('确定要退出当前账号吗?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text('退出'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true) {
|
|
ref.read(settingsViewModelProvider.notifier).logout();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 个人资料卡 ─────────────────────────────────────────────────────────────────
|
|
|
|
class _ProfileCard extends StatelessWidget {
|
|
const _ProfileCard({required this.state, required this.onTap});
|
|
|
|
final SettingsState state;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
_Avatar(avatarUrl: state.avatarUrl, isLoading: state.isLoading),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: state.isLoading
|
|
? _ProfileSkeleton()
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
state.nickname.isEmpty ? '加载中…' : state.nickname,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
state.maskedContact.isNotEmpty
|
|
? state.maskedContact
|
|
: 'UID: ${state.uid}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
if (state.maskedContact.isNotEmpty && state.uid > 0)
|
|
Text(
|
|
'UID: ${state.uid}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Avatar extends StatelessWidget {
|
|
const _Avatar({required this.avatarUrl, required this.isLoading});
|
|
|
|
final String? avatarUrl;
|
|
final bool isLoading;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isLoading) {
|
|
return const CircleAvatar(
|
|
radius: 28,
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
);
|
|
}
|
|
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
|
return CircleAvatar(
|
|
radius: 28,
|
|
backgroundImage: NetworkImage(avatarUrl!),
|
|
);
|
|
}
|
|
return const CircleAvatar(
|
|
radius: 28,
|
|
child: Icon(Icons.person, size: 28),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProfileSkeleton extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
height: 16,
|
|
width: 120,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
height: 12,
|
|
width: 80,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade200,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 设置分组 ───────────────────────────────────────────────────────────────────
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
const _SectionLabel(this.text);
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RowConfig {
|
|
const _RowConfig({
|
|
required this.icon,
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String? subtitle;
|
|
final VoidCallback onTap;
|
|
}
|
|
|
|
class _SettingsCard extends StatelessWidget {
|
|
const _SettingsCard({required this.items});
|
|
|
|
final List<_RowConfig> items;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: Column(
|
|
children: [
|
|
for (int i = 0; i < items.length; i++) ...[
|
|
if (i > 0)
|
|
Divider(
|
|
height: 1,
|
|
indent: 52,
|
|
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
|
),
|
|
_SettingsRow(config: items[i]),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsRow extends StatelessWidget {
|
|
const _SettingsRow({required this.config});
|
|
|
|
final _RowConfig config;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: Icon(config.icon, size: 22),
|
|
title: Text(config.title),
|
|
subtitle: config.subtitle != null ? Text(config.subtitle!) : null,
|
|
trailing: const Icon(Icons.chevron_right, size: 18, color: Colors.grey),
|
|
onTap: config.onTap,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 退出登录 ───────────────────────────────────────────────────────────────────
|
|
|
|
class _LogoutButton extends StatelessWidget {
|
|
const _LogoutButton({required this.isLoading, required this.onTap});
|
|
|
|
final bool isLoading;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: isLoading ? null : onTap,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
|
)
|
|
: const Text('退出登录', style: TextStyle(fontSize: 16)),
|
|
),
|
|
);
|
|
}
|
|
}
|