diff --git a/Doc/mine_tab_architecture.md b/Doc/mine_tab_architecture.md index dcce6bb..e1e8d8d 100644 --- a/Doc/mine_tab_architecture.md +++ b/Doc/mine_tab_architecture.md @@ -1,6 +1,6 @@ # 我的(Mine)Tab — 架构文档 -> 对应 Gitea issues #5–#13 +> 对应 Gitea issues #5–#13,#39–#41(UI 重设计) > 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile --- @@ -152,7 +152,57 @@ settingsViewModelProvider --- -## 7. 待完成事项 +## 7. UI 重设计(#39 / #40 / #41) + +### 7.1 ProfileHeroCard (#39 / #41) + +| 元素 | 规格 | +|------|------| +| 头像 | 72pt 圆形;无头像时 8 色渐变占位(uid%8) | +| 昵称 | fontWeight w700,titleMedium | +| Handle | `@J{uid}`,bodySmall onSurfaceVariant | +| 手机号 | 掩码 `+CC ***XXXX`,bodySmall | +| Bio | 非空显示,为空显示「添加一句话简介」(斜体,半透明) | +| AppBar | compact,右侧:QR 图标 + 编辑铅笔 | + +渐变色方案(`_ProfileHeroCard._gradients[uid.abs() % 8]`): +``` +0: [#4776E6, #8E54E9] 1: [#11998E, #38EF7D] +2: [#FC466B, #3F5EFB] 3: [#F7971E, #FFD200] +4: [#56CCF2, #2F80ED] 5: [#EB3349, #F45C43] +6: [#1FA2FF, #12D8FA] 7: [#9D50BB, #6E48AA] +``` + +### 7.2 彩色图标行与分组卡片 (#40) + +`_IconBox`:36pt 圆角正方形(8pt)白色图标,iOS Settings 风格。 + +| 卡片组 | 菜单项 | 颜色 | +|--------|--------|------| +| 账户 | 我的钱包 | #FFAA5B | +| | 账户安全 | #8A5CF6 | +| 工具 | 收藏 | #FFAF45 | +| | 最近呼叫 | #4CB050 | +| | 链接设备 | #5667FF | +| | 聊天文件夹 | #F2994A | +| 偏好设置 | 通知和声音 | #FF8B5E | +| | 隐私设置 | #0BB8A9 | +| | 黑名单 | #FF4B4B | +| | 语言 | #5667FF | +| | 主题 | #8A5CF6 | +| 关于 | 用户协议 | gray | +| | 隐私政策 | gray | +| | 版本号(静态)| gray,无 chevron | + +### 7.3 SettingsState bio 字段 (#41) + +- `SettingsState.bio: String`(默认 `''`) +- `SettingsViewModel.loadProfile()` 赋值 `bio: profile.bio` +- 数据来源:`ProfileResponse.bio` → `GET /app/api/user/profile` + +--- + +## 8. 待完成事项 - **#6 头像上传**:接入 CDN upload(参考 iOS CDN 流程) - **#8 主题持久化**:解开 `ThemeModeNotifier.build()` 和 `setMode()` 中的 TODO diff --git a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart index 9af5a8c..b66389a 100644 --- a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart +++ b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart @@ -12,6 +12,7 @@ class SettingsState { final String? avatarUrl; final String maskedContact; final int uid; + final String bio; final bool isLoading; final bool isLoggingOut; final String? error; @@ -21,6 +22,7 @@ class SettingsState { this.avatarUrl, this.maskedContact = '', this.uid = 0, + this.bio = '', this.isLoading = false, this.isLoggingOut = false, this.error, @@ -32,6 +34,7 @@ class SettingsState { bool clearAvatarUrl = false, String? maskedContact, int? uid, + String? bio, bool? isLoading, bool? isLoggingOut, String? error, @@ -42,6 +45,7 @@ class SettingsState { avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl), maskedContact: maskedContact ?? this.maskedContact, uid: uid ?? this.uid, + bio: bio ?? this.bio, isLoading: isLoading ?? this.isLoading, isLoggingOut: isLoggingOut ?? this.isLoggingOut, error: clearError ? null : (error ?? this.error), @@ -84,6 +88,7 @@ class SettingsViewModel extends Notifier { avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic, maskedContact: _maskContact(profile.contact, profile.countryCode), uid: profile.uid, + bio: profile.bio, ); } catch (e) { state = state.copyWith(isLoading: false, error: e.toString()); diff --git a/apps/im_app/lib/features/settings/view/settings_page.dart b/apps/im_app/lib/features/settings/view/settings_page.dart index 3bc29b7..e0413c9 100644 --- a/apps/im_app/lib/features/settings/view/settings_page.dart +++ b/apps/im_app/lib/features/settings/view/settings_page.dart @@ -1,22 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:im_app/core/foundation/config.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart'; -/// 我的页面(原设置页) +/// 我的页(#39 / #40 / #41) /// -/// 结构: -/// ┌─ 个人资料卡 ──────────────────────────────────────────┐ -/// │ 头像 昵称 │ -/// │ 手机号(掩码) UID: xxx │ -/// └──────────────────────────────────────────────────────┘ -/// 偏好设置 → 主题 / 语言 / 通知 -/// 工具 → 黑名单 / 网络诊断 -/// 关于 → 关于本应用 -/// [退出登录] +/// ## 结构(iOS SettingsView.swift 对齐) +/// +/// - ProfileHeroCard:72pt 渐变头像 + 昵称 + @J{uid} handle + 手机号 + bio +/// - AppBar:compact,右侧 QR 图标 + 编辑铅笔 +/// - 卡片组 1(账户):我的钱包 / 账户安全 +/// - 卡片组 2(工具):收藏 / 最近呼叫 / 链接设备 / 聊天文件夹 +/// - 卡片组 3「偏好设置」:通知和声音 / 隐私设置 / 黑名单 / 语言 / 主题 +/// - 卡片组 4「关于」:用户协议 / 隐私政策 / 版本号 +/// - 退出登录(全宽红色按钮) class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); + // ── iOS 色板 ────────────────────────────────────────────────────────────── + static const Color _walletColor = Color(0xFFFFAA5B); + static const Color _securityColor = Color(0xFF8A5CF6); + static const Color _favoriteColor = Color(0xFFFFAF45); + static const Color _callColor = Color(0xFF4CB050); + static const Color _deviceColor = Color(0xFF5667FF); + static const Color _folderColor = Color(0xFFF2994A); + static const Color _notifColor = Color(0xFFFF8B5E); + static const Color _privacyColor = Color(0xFF0BB8A9); + static const Color _blockColor = Color(0xFFFF4B4B); + static const Color _langColor = Color(0xFF5667FF); + static const Color _themeColor = Color(0xFF8A5CF6); + @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(settingsViewModelProvider); @@ -26,75 +40,158 @@ class SettingsPage extends ConsumerWidget { backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, body: CustomScrollView( slivers: [ - SliverAppBar.large( + // ── AppBar(compact,QR + edit) ──────────────────────────────────── + SliverAppBar( title: const Text('我的'), + pinned: true, automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner_rounded), + tooltip: '我的二维码', + onPressed: () {}, // TODO: QR code page + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: '编辑资料', + onPressed: () => vm.navigateToEditProfile(context), + ), + const SizedBox(width: 4), + ], ), - 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: [ + // ── ProfileHeroCard ─────────────────────────────────────── + _ProfileHeroCard( + state: state, + onTap: () => vm.navigateToEditProfile(context), + ), + const SizedBox(height: 16), + + // ── 卡片组 1:账户 ──────────────────────────────────────── + _SettingsCard( + items: [ + _RowConfig( + icon: Icons.credit_card_rounded, + iconColor: _walletColor, + title: '我的钱包', + onTap: () {}, // TODO: 钱包页 + ), + _RowConfig( + icon: Icons.shield_rounded, + iconColor: _securityColor, + title: '账户安全', + onTap: () {}, // TODO: 账户安全页 + ), + ], + ), + const SizedBox(height: 16), + + // ── 卡片组 2:工具 ──────────────────────────────────────── + _SettingsCard( + items: [ + _RowConfig( + icon: Icons.star_rounded, + iconColor: _favoriteColor, + title: '收藏', + onTap: () {}, // TODO: 收藏页 + ), + _RowConfig( + icon: Icons.phone_rounded, + iconColor: _callColor, + title: '最近呼叫', + onTap: () {}, // TODO: 呼叫记录页 + ), + _RowConfig( + icon: Icons.laptop_rounded, + iconColor: _deviceColor, + title: '链接设备', + onTap: () {}, // TODO: 设备管理页 + ), + _RowConfig( + icon: Icons.folder_rounded, + iconColor: _folderColor, + title: '聊天文件夹', + onTap: () {}, // TODO: Issue #11 + ), + ], + ), + const SizedBox(height: 16), + + // ── 卡片组 3:偏好设置 ──────────────────────────────────── _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: '通知', + icon: Icons.notifications_rounded, + iconColor: _notifColor, + title: '通知和声音', onTap: () {}, // TODO: 通知设置页 ), - ], - ), - const SizedBox(height: 16), - _SectionLabel('工具'), - _SettingsCard( - items: [ _RowConfig( - icon: Icons.folder_outlined, - title: '聊天文件夹', - onTap: () {}, // TODO: Issue #11 + icon: Icons.lock_rounded, + iconColor: _privacyColor, + title: '隐私设置', + onTap: () {}, // TODO: 隐私设置页 ), _RowConfig( - icon: Icons.block, + icon: Icons.block_rounded, + iconColor: _blockColor, title: '黑名单', onTap: () => vm.navigateToBlocklist(context), ), _RowConfig( - icon: Icons.network_check, - title: '网络诊断', - onTap: () => vm.navigateToNetworkDiagnostics(context), + icon: Icons.language_rounded, + iconColor: _langColor, + title: '语言', + onTap: () => vm.navigateToLanguage(context), + ), + _RowConfig( + icon: Icons.palette_rounded, + iconColor: _themeColor, + title: '主题', + onTap: () => vm.navigateToTheme(context), ), ], ), const SizedBox(height: 16), + + // ── 卡片组 4:关于 ──────────────────────────────────────── _SectionLabel('关于'), _SettingsCard( items: [ _RowConfig( - icon: Icons.info_outline, - title: '关于本应用', + icon: Icons.description_outlined, + iconColor: Colors.grey, + title: '用户协议', onTap: () => vm.navigateToAbout(context), ), + _RowConfig( + icon: Icons.privacy_tip_outlined, + iconColor: Colors.grey, + title: '隐私政策', + onTap: () => vm.navigateToAbout(context), + ), + _RowConfig( + icon: Icons.info_outline_rounded, + iconColor: Colors.grey, + title: '版本号', + subtitle: AppConfig.appVersion.isEmpty + ? '1.0.0' + : AppConfig.appVersion, + onTap: () {}, + showChevron: false, + ), ], ), const SizedBox(height: 24), + + // ── 退出登录 ─────────────────────────────────────────────── _LogoutButton( isLoading: state.isLoggingOut, onTap: () => _confirmLogout(context, ref), @@ -134,16 +231,32 @@ class SettingsPage extends ConsumerWidget { } } -// ── 个人资料卡 ───────────────────────────────────────────────────────────────── +// ── ProfileHeroCard (#39 / #41) ─────────────────────────────────────────────── -class _ProfileCard extends StatelessWidget { - const _ProfileCard({required this.state, required this.onTap}); +/// iOS SettingsView.swift ProfileHeroCard 对齐 +/// +/// 8 色渐变占位(uid % 8)+ 昵称 + @J{uid} handle + 手机号 + bio +class _ProfileHeroCard extends StatelessWidget { + const _ProfileHeroCard({required this.state, required this.onTap}); final SettingsState state; final VoidCallback onTap; + // 8 色渐变方案(iOS 端 ProfileHeroCard 同款) + static const _gradients = [ + [Color(0xFF4776E6), Color(0xFF8E54E9)], // 0: 蓝紫 + [Color(0xFF11998E), Color(0xFF38EF7D)], // 1: 青绿 + [Color(0xFFFC466B), Color(0xFF3F5EFB)], // 2: 粉蓝 + [Color(0xFFF7971E), Color(0xFFFFD200)], // 3: 橙黄 + [Color(0xFF56CCF2), Color(0xFF2F80ED)], // 4: 天蓝 + [Color(0xFFEB3349), Color(0xFFF45C43)], // 5: 红橙 + [Color(0xFF1FA2FF), Color(0xFF12D8FA)], // 6: 蓝青 + [Color(0xFF9D50BB), Color(0xFF6E48AA)], // 7: 深紫 + ]; + @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Card( child: InkWell( onTap: onTap, @@ -151,8 +264,13 @@ class _ProfileCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _Avatar(avatarUrl: state.avatarUrl, isLoading: state.isLoading), + _HeroAvatar( + avatarUrl: state.avatarUrl, + uid: state.uid, + isLoading: state.isLoading, + ), const SizedBox(width: 16), Expanded( child: state.isLoading @@ -162,26 +280,45 @@ class _ProfileCard extends StatelessWidget { children: [ Text( state.nickname.isEmpty ? '加载中…' : state.nickname, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), ), - 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) + const SizedBox(height: 2), + if (state.uid > 0) Text( - 'UID: ${state.uid}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), + '@J${state.uid}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: cs.onSurfaceVariant), ), + if (state.maskedContact.isNotEmpty) + Text( + state.maskedContact, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 2), + Text( + state.bio.isNotEmpty ? state.bio : '添加一句话简介', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: state.bio.isNotEmpty + ? cs.onSurfaceVariant + : cs.onSurfaceVariant.withOpacity(0.5), + fontStyle: state.bio.isEmpty + ? FontStyle.italic + : FontStyle.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ), @@ -194,33 +331,51 @@ class _ProfileCard extends StatelessWidget { } } -class _Avatar extends StatelessWidget { - const _Avatar({required this.avatarUrl, required this.isLoading}); +class _HeroAvatar extends StatelessWidget { + const _HeroAvatar({ + required this.avatarUrl, + required this.uid, + required this.isLoading, + }); final String? avatarUrl; + final int uid; final bool isLoading; @override Widget build(BuildContext context) { + const radius = 36.0; // 72pt diameter + if (isLoading) { return const CircleAvatar( - radius: 28, + radius: radius, child: SizedBox( - width: 20, - height: 20, + width: 24, + height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ); } if (avatarUrl != null && avatarUrl!.isNotEmpty) { return CircleAvatar( - radius: 28, + radius: radius, backgroundImage: NetworkImage(avatarUrl!), ); } - return const CircleAvatar( - radius: 28, - child: Icon(Icons.person, size: 28), + // 渐变占位(uid % 8) + final colors = _ProfileHeroCard._gradients[uid.abs() % 8]; + return Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: colors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Icon(Icons.person, size: 36, color: Colors.white), ); } } @@ -253,11 +408,10 @@ class _ProfileSkeleton extends StatelessWidget { } } -// ── 设置分组 ─────────────────────────────────────────────────────────────────── +// ── 设置分组 (#40) ───────────────────────────────────────────────────────────── class _SectionLabel extends StatelessWidget { const _SectionLabel(this.text); - final String text; @override @@ -278,20 +432,23 @@ class _SectionLabel extends StatelessWidget { class _RowConfig { const _RowConfig({ required this.icon, + required this.iconColor, required this.title, this.subtitle, required this.onTap, + this.showChevron = true, }); final IconData icon; + final Color iconColor; final String title; final String? subtitle; final VoidCallback onTap; + final bool showChevron; } class _SettingsCard extends StatelessWidget { const _SettingsCard({required this.items}); - final List<_RowConfig> items; @override @@ -303,7 +460,7 @@ class _SettingsCard extends StatelessWidget { if (i > 0) Divider( height: 1, - indent: 52, + indent: 60, color: Theme.of(context).dividerColor.withOpacity(0.5), ), _SettingsRow(config: items[i]), @@ -314,28 +471,58 @@ class _SettingsCard extends StatelessWidget { } } +/// iOS 风格行:36pt 彩色圆角正方形图标 + 标题 + 可选副标题 + chevron 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), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + leading: _IconBox(icon: config.icon, color: config.iconColor), title: Text(config.title), - subtitle: config.subtitle != null ? Text(config.subtitle!) : null, - trailing: const Icon(Icons.chevron_right, size: 18, color: Colors.grey), + subtitle: config.subtitle != null + ? Text( + config.subtitle!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.grey), + ) + : null, + trailing: config.showChevron + ? const Icon(Icons.chevron_right, size: 18, color: Colors.grey) + : null, onTap: config.onTap, ); } } +/// 36pt 圆角正方形彩色图标盒(iOS Settings icon style) +class _IconBox extends StatelessWidget { + const _IconBox({required this.icon, required this.color}); + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: Colors.white, size: 20), + ); + } +} + // ── 退出登录 ─────────────────────────────────────────────────────────────────── class _LogoutButton extends StatelessWidget { const _LogoutButton({required this.isLoading, required this.onTap}); - final bool isLoading; final VoidCallback onTap;