diff --git a/Doc/mine_tab_architecture.md b/Doc/mine_tab_architecture.md new file mode 100644 index 0000000..dcce6bb --- /dev/null +++ b/Doc/mine_tab_architecture.md @@ -0,0 +1,164 @@ +# 我的(Mine)Tab — 架构文档 + +> 对应 Gitea issues #5–#13 +> 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile + +--- + +## 1. 功能范围 + +| Issue | 功能 | 状态 | +|-------|------|------| +| #5 | Tab 重命名 & 个人资料卡片 | ✅ 已实现 | +| #6 | 编辑个人资料(昵称/bio/头像) | ✅ 框架已建(CDN 上传待 #6 后续) | +| #7 | 退出登录 | ✅ 已实现 | +| #8 | 主题持久化 | ⏳ TODO(解开 ThemeModeNotifier 注释) | +| #9 | 语言设置 | ✅ UI 框架(l10n_sdk 待接入) | +| #10 | 黑名单管理 | ✅ 页面框架(API 待实现) | +| #11 | 聊天文件夹 | ⏳ TODO stub | +| #12 | 网络诊断 | ✅ 已实现(4步诊断) | +| #13 | 关于 / 版本 | ✅ 已实现 | + +--- + +## 2. 目录结构 + +``` +features/settings/ +├── di/ +│ └── settings_providers.dart # UseCase DI 装配 +├── presentation/ +│ ├── settings_view_model.dart # 我的页 ViewModel (NotifierProvider) +│ ├── edit_profile_view_model.dart # 编辑资料 ViewModel +│ ├── network_diagnostics_view_model.dart # 网络诊断 ViewModel +│ └── theme_view_model.dart # 主题 ViewModel (@riverpod) +├── usecases/ +│ ├── fetch_profile_usecase.dart # GET /app/api/user/profile +│ ├── update_profile_usecase.dart # POST /app/api/user/update-profile +│ ├── logout_usecase.dart # POST /app/api/auth/logout + WS + DB +│ └── set_theme_usecase.dart # 幂等主题切换 +└── view/ + ├── settings_page.dart # 我的主页 + ├── edit_profile_page.dart # 编辑资料页 + ├── language_page.dart # 语言选择页 + ├── blocklist_page.dart # 黑名单页(框架) + ├── network_diagnostics_page.dart # 网络诊断页 + ├── about_page.dart # 关于页 + ├── theme_view.dart # 主题选择页 + └── widgets/ + ├── settings_section_header.dart + └── theme_option_tile.dart + +data/remote/ +├── get_profile_request.dart # GET /app/api/user/profile(已有) +└── update_profile_request.dart # POST /app/api/user/update-profile(新增) +``` + +--- + +## 3. 数据流 + +### 3.1 资料加载 + +``` +SettingsPage (build) + └─ ref.watch(settingsViewModelProvider) + └─ SettingsViewModel.build() + └─ Future.microtask(loadProfile) + └─ FetchProfileUseCase.execute() + └─ NetworksSdkApi.executeRequest(GetProfileRequest()) + └─ GET /app/api/user/profile (JWT token in header) + └─ ProfileResponse → SettingsState {nickname, avatarUrl, maskedContact, uid} +``` + +### 3.2 退出登录 + +``` +SettingsPage._confirmLogout() + └─ AlertDialog confirm + └─ SettingsViewModel.logout() + ├─ LogoutUseCase.execute() + │ ├─ AuthRepository.logout() → POST /app/api/auth/logout + │ ├─ SocketManager.disconnect() → 断开 WebSocket + │ └─ StorageSdkLifecycle.closeDatabase() + └─ AuthNotifier.logout() → go_router 重定向 /login +``` + +### 3.3 编辑资料保存 + +``` +EditProfilePage → 保存按钮 + └─ EditProfileViewModel.save() + └─ UpdateProfileUseCase.execute(nickname, bio, profilePicUrl) + └─ NetworksSdkApi.executeRequest(UpdateProfileRequest) + └─ POST /app/api/user/update-profile + └─ SettingsViewModel.loadProfile() (刷新资料卡) +``` + +--- + +## 4. API 端点 + +| 操作 | Method | Path | +|------|--------|------| +| 获取当前用户资料 | GET | `/app/api/user/profile` | +| 更新用户资料 | POST | `/app/api/user/update-profile` | +| 退出登录 | POST | `/app/api/auth/logout` | +| 黑名单列表(待实现) | GET | `/app/api/account/block/list` | +| 解除拉黑(待实现) | POST | `/app/api/account/block/remove` | +| 聊天文件夹(待实现) | POST | `/app/api/account/store/get-store` | + +--- + +## 5. Provider 设计 + +所有 Settings ViewModel 使用 `Notifier` + 手动 `NotifierProvider`,**不使用 `@riverpod` 代码生成**,避免额外 build_runner 依赖: + +```dart +class SettingsViewModel extends Notifier { ... } + +final settingsViewModelProvider = NotifierProvider( + SettingsViewModel.new, +); +``` + +DI 链路: + +``` +settingsViewModelProvider + └─ fetchProfileUseCaseProvider → networkSdkApiProvider + └─ logoutUseCaseProvider + ├─ authRepositoryProvider → networkSdkApiProvider + ├─ socketManagerProvider + └─ storageSdkProvider +``` + +--- + +## 6. 路由 + +新增 Shell 外全屏路由(`parentNavigatorKey: _rootKey`),TabBar 在子页面隐藏: + +``` +/settings/edit-profile EditProfilePage +/settings/blocklist BlocklistPage +/settings/language LanguagePage +/settings/network-diagnostics NetworkDiagnosticsPage +/settings/about AboutPage +/settings/theme ThemeView(原有) +``` + +认证守卫 `auth_guard.dart` switch 已补全上述路由。 + +--- + +## 7. 待完成事项 + +- **#6 头像上传**:接入 CDN upload(参考 iOS CDN 流程) +- **#8 主题持久化**:解开 `ThemeModeNotifier.build()` 和 `setMode()` 中的 TODO +- **#10 黑名单 API**:实现 `FetchBlocklistUseCase` + `UnblockUseCase` +- **#11 聊天文件夹**:`ChatCategoryViewModel` + `account/store` API +- **build_runner**:`UpdateProfileRequest` 使用 `@ApiRequest`,需执行: + ```bash + cd apps/im_app && dart run build_runner build --delete-conflicting-outputs + ``` diff --git a/apps/im_app/lib/app/di/app_providers.dart b/apps/im_app/lib/app/di/app_providers.dart index cc4d98c..7465f9c 100644 --- a/apps/im_app/lib/app/di/app_providers.dart +++ b/apps/im_app/lib/app/di/app_providers.dart @@ -19,11 +19,16 @@ import 'package:im_app/app/di/network_provider.dart'; /// - `login` / `logout`:同步更新安全存储 class AuthNotifier extends ChangeNotifier { bool _isLoggedIn = false; + int? _currentUid; bool get isLoggedIn => _isLoggedIn; - void login() { + /// 登录用户的 UID,登录成功后由 LoginViewModel 写入 + int? get currentUid => _currentUid; + + void login({required int uid}) { _isLoggedIn = true; + _currentUid = uid; // TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入: // 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次) // 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem) @@ -33,6 +38,7 @@ class AuthNotifier extends ChangeNotifier { void logout() { _isLoggedIn = false; + _currentUid = null; // TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥: // cipherSdk.clearActiveKeyPair() // cipherSdk.clearDerivedKeyCache() diff --git a/apps/im_app/lib/app/router/app_route_name.dart b/apps/im_app/lib/app/router/app_route_name.dart index 0f8ddbd..a0757cb 100644 --- a/apps/im_app/lib/app/router/app_route_name.dart +++ b/apps/im_app/lib/app/router/app_route_name.dart @@ -68,6 +68,11 @@ enum AppRouteName { // ── Settings 子路由 ─────────────────────────────────────────────────────── settingsTheme('/settings/theme'), + settingsEditProfile('/settings/edit-profile'), + settingsBlocklist('/settings/blocklist'), + settingsLanguage('/settings/language'), + settingsNetworkDiagnostics('/settings/network-diagnostics'), + settingsAbout('/settings/about'), // ── 全屏页面(无底部导航栏)────────────────────────────────────────────── login('/login'); diff --git a/apps/im_app/lib/app/router/app_router.dart b/apps/im_app/lib/app/router/app_router.dart index 7ceb675..e85256b 100644 --- a/apps/im_app/lib/app/router/app_router.dart +++ b/apps/im_app/lib/app/router/app_router.dart @@ -10,6 +10,11 @@ import 'package:im_app/features/contact/view/contact_page.dart'; import 'package:im_app/features/login/view/login_page.dart'; import 'package:im_app/features/settings/view/settings_page.dart'; import 'package:im_app/features/settings/view/theme_view.dart'; +import 'package:im_app/features/settings/view/edit_profile_page.dart'; +import 'package:im_app/features/settings/view/blocklist_page.dart'; +import 'package:im_app/features/settings/view/language_page.dart'; +import 'package:im_app/features/settings/view/network_diagnostics_page.dart'; +import 'package:im_app/features/settings/view/about_page.dart'; import 'package:im_app/app/di/app_providers.dart'; import 'package:im_app/app/router/app_route_name.dart'; import 'package:im_app/app/router/guards/auth_guard.dart'; @@ -152,6 +157,31 @@ final routerProvider = Provider((ref) { path: AppRouteName.settingsTheme.path, builder: (context, state) => const ThemeView(), ), + GoRoute( + parentNavigatorKey: _rootKey, + path: AppRouteName.settingsEditProfile.path, + builder: (context, state) => const EditProfilePage(), + ), + GoRoute( + parentNavigatorKey: _rootKey, + path: AppRouteName.settingsBlocklist.path, + builder: (context, state) => const BlocklistPage(), + ), + GoRoute( + parentNavigatorKey: _rootKey, + path: AppRouteName.settingsLanguage.path, + builder: (context, state) => const LanguagePage(), + ), + GoRoute( + parentNavigatorKey: _rootKey, + path: AppRouteName.settingsNetworkDiagnostics.path, + builder: (context, state) => const NetworkDiagnosticsPage(), + ), + GoRoute( + parentNavigatorKey: _rootKey, + path: AppRouteName.settingsAbout.path, + builder: (context, state) => const AboutPage(), + ), GoRoute( parentNavigatorKey: _rootKey, path: AppRouteName.login.path, diff --git a/apps/im_app/lib/app/router/guards/auth_guard.dart b/apps/im_app/lib/app/router/guards/auth_guard.dart index 1cb5ce1..81d336b 100644 --- a/apps/im_app/lib/app/router/guards/auth_guard.dart +++ b/apps/im_app/lib/app/router/guards/auth_guard.dart @@ -40,6 +40,11 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) { case AppRouteName.contact: case AppRouteName.settings: case AppRouteName.settingsTheme: + case AppRouteName.settingsEditProfile: + case AppRouteName.settingsBlocklist: + case AppRouteName.settingsLanguage: + case AppRouteName.settingsNetworkDiagnostics: + case AppRouteName.settingsAbout: case AppRouteName.chatDBTest: // 受保护路由 → 未登录跳登录页 return isLoggedIn ? null : AppRouteName.login.path; diff --git a/apps/im_app/lib/core/foundation/api_paths.dart b/apps/im_app/lib/core/foundation/api_paths.dart index aa045d0..dd6650d 100644 --- a/apps/im_app/lib/core/foundation/api_paths.dart +++ b/apps/im_app/lib/core/foundation/api_paths.dart @@ -25,6 +25,13 @@ class ApiPaths { static const chatSendMessage = '/app/api/chat/send-message'; static const chatHistory = '/app/api/chat/history'; + // ── Account ── + static const accountRequestInfo = '/app/api/account/request-info'; + static const accountBlocklist = '/app/api/account/block/list'; + static const accountUnblock = '/app/api/account/block/remove'; + static const accountStoreGet = '/app/api/account/store/get-store'; + static const accountStoreUpdate = '/app/api/account/store/update-store'; + // ── Upload ── static const uploadFile = '/app/api/upload/file'; diff --git a/apps/im_app/lib/data/remote/update_profile_request.dart b/apps/im_app/lib/data/remote/update_profile_request.dart new file mode 100644 index 0000000..45fcb18 --- /dev/null +++ b/apps/im_app/lib/data/remote/update_profile_request.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/core/foundation/api_paths.dart'; + +part 'update_profile_request.g.dart'; + +/// # /user/update-profile — 更新用户资料(POST 请求) +/// +/// ## 数据流位置 +/// +/// ``` +/// UpdateProfileUseCase.execute() +/// → _client.executeRequest( ★ UpdateProfileRequest ★ ) ← 你在这里 +/// → 服务端 POST /app/api/user/update-profile +/// → 响应 {"code": 0, "message": "ok"} → null(无 data) +/// ``` +@ApiRequest( + path: ApiPaths.userUpdateProfile, + method: HttpMethod.post, +) +class UpdateProfileRequest extends ApiRequestable + with _$UpdateProfileRequestApi { + final String nickname; + final String? bio; + @JsonKey(name: 'profile_pic') + final String? profilePic; + + UpdateProfileRequest({ + required this.nickname, + this.bio, + this.profilePic, + }); +} diff --git a/apps/im_app/lib/features/app_tab/view/app_tab.dart b/apps/im_app/lib/features/app_tab/view/app_tab.dart index efc34d1..44f0886 100644 --- a/apps/im_app/lib/features/app_tab/view/app_tab.dart +++ b/apps/im_app/lib/features/app_tab/view/app_tab.dart @@ -41,9 +41,9 @@ class AppTab extends StatelessWidget { label: '联系人', ), BottomNavigationBarItem( - icon: Icon(Icons.settings_outlined), - activeIcon: Icon(Icons.settings), - label: '设置', + icon: Icon(Icons.person_outline), + activeIcon: Icon(Icons.person), + label: '我的', ), ], ), diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart index 51893d5..e96abed 100644 --- a/apps/im_app/lib/features/login/presentation/login_view_model.dart +++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart @@ -72,7 +72,7 @@ class LoginViewModel extends _$LoginViewModel { state = state.copyWith(isLoading: true, error: null); try { - await ref + final user = await ref .read(loginUseCaseProvider) .verifyAndLogin( countryCode: state.countryCode, @@ -83,7 +83,7 @@ class LoginViewModel extends _$LoginViewModel { // 成功后触发路由守卫重定向。 // 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state。 if (!ref.mounted) return; - ref.read(authNotifierProvider).login(); + ref.read(authNotifierProvider).login(uid: user.uid); } on FormatException catch (e) { if (!ref.mounted) return; state = state.copyWith(error: e.message, isLoading: false); diff --git a/apps/im_app/lib/features/settings/di/settings_providers.dart b/apps/im_app/lib/features/settings/di/settings_providers.dart index 0910eb4..476233d 100644 --- a/apps/im_app/lib/features/settings/di/settings_providers.dart +++ b/apps/im_app/lib/features/settings/di/settings_providers.dart @@ -1,21 +1,54 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:im_app/app/di/network_provider.dart'; +import 'package:im_app/app/di/db_provider.dart'; +import 'package:im_app/core/services/socket_manager.dart'; +import 'package:im_app/features/login/di/auth_providers.dart'; import 'package:im_app/features/settings/usecases/set_theme_usecase.dart'; +import 'package:im_app/features/settings/usecases/fetch_profile_usecase.dart'; +import 'package:im_app/features/settings/usecases/logout_usecase.dart'; +import 'package:im_app/features/settings/usecases/update_profile_usecase.dart'; /// Settings feature DI 装配 /// -/// 手动装配 UseCase Provider,ViewModel 通过此处获取依赖。 -/// /// ``` +/// SettingsViewModel +/// → fetchProfileUseCaseProvider → GET /app/api/user/profile +/// → logoutUseCaseProvider → POST /app/api/auth/logout + WS + DB +/// +/// EditProfileViewModel +/// → updateProfileUseCaseProvider → POST /app/api/user/update-profile +/// /// ThemeViewModel -/// → ref.read(setThemeUseCaseProvider) ← 此处装配 -/// → SetThemeUseCase(幂等校验) -/// → onApply → ThemeModeNotifier.setMode()(内存 + 持久化 TODO) +/// → setThemeUseCaseProvider → ThemeModeNotifier(内存 + 持久化 TODO) /// ``` -// ── UseCase ─────────────────────────────────────────────────────────────────── +// ── Theme ───────────────────────────────────────────────────────────────────── /// 设置主题用例 Provider final setThemeUseCaseProvider = Provider( (_) => const SetThemeUseCase(), ); + +// ── Profile ──────────────────────────────────────────────────────────────────── + +/// 获取当前用户资料用例 Provider +final fetchProfileUseCaseProvider = Provider((ref) { + return FetchProfileUseCase(client: ref.read(networkSdkApiProvider)); +}); + +/// 更新用户资料用例 Provider +final updateProfileUseCaseProvider = Provider((ref) { + return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider)); +}); + +// ── Auth ─────────────────────────────────────────────────────────────────────── + +/// 退出登录用例 Provider +final logoutUseCaseProvider = Provider((ref) { + return LogoutUseCase( + authRepository: ref.read(authRepositoryProvider), + socketManager: ref.read(socketManagerProvider), + storageApi: ref.read(storageSdkProvider), + ); +}); diff --git a/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart b/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart new file mode 100644 index 0000000..6e56e00 --- /dev/null +++ b/apps/im_app/lib/features/settings/presentation/edit_profile_view_model.dart @@ -0,0 +1,101 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:im_app/features/settings/di/settings_providers.dart'; + +/// 编辑个人资料页状态 +class EditProfileState { + final String nickname; + final String bio; + final String? avatarUrl; + final bool isLoading; + final bool isSaving; + final String? error; + + const EditProfileState({ + this.nickname = '', + this.bio = '', + this.avatarUrl, + this.isLoading = false, + this.isSaving = false, + this.error, + }); + + EditProfileState copyWith({ + String? nickname, + String? bio, + String? avatarUrl, + bool clearAvatarUrl = false, + bool? isLoading, + bool? isSaving, + String? error, + bool clearError = false, + }) { + return EditProfileState( + nickname: nickname ?? this.nickname, + bio: bio ?? this.bio, + avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl), + isLoading: isLoading ?? this.isLoading, + isSaving: isSaving ?? this.isSaving, + error: clearError ? null : (error ?? this.error), + ); + } +} + +/// 编辑个人资料 ViewModel +class EditProfileViewModel extends Notifier { + @override + EditProfileState build() { + Future.microtask(_loadCurrentProfile); + return const EditProfileState(isLoading: true); + } + + Future _loadCurrentProfile() async { + try { + final profile = await ref.read(fetchProfileUseCaseProvider).execute(); + state = state.copyWith( + isLoading: false, + nickname: profile.nickname, + bio: profile.bio, + avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + void updateNickname(String value) { + state = state.copyWith(nickname: value, clearError: true); + } + + void updateBio(String value) { + state = state.copyWith(bio: value); + } + + // TODO Issue #6: 头像上传(CDN 流程) + // void pickAndUploadAvatar() async { ... } + + Future save() async { + if (state.nickname.trim().isEmpty) { + state = state.copyWith(error: '昵称不能为空'); + return false; + } + state = state.copyWith(isSaving: true, clearError: true); + try { + await ref.read(updateProfileUseCaseProvider).execute( + nickname: state.nickname.trim(), + bio: state.bio.trim().isEmpty ? null : state.bio.trim(), + profilePicUrl: state.avatarUrl, + ); + state = state.copyWith(isSaving: false); + return true; + } catch (e) { + state = state.copyWith(isSaving: false, error: e.toString()); + return false; + } + } +} + +final editProfileViewModelProvider = + NotifierProvider( + EditProfileViewModel.new, +); diff --git a/apps/im_app/lib/features/settings/presentation/network_diagnostics_view_model.dart b/apps/im_app/lib/features/settings/presentation/network_diagnostics_view_model.dart new file mode 100644 index 0000000..93108d3 --- /dev/null +++ b/apps/im_app/lib/features/settings/presentation/network_diagnostics_view_model.dart @@ -0,0 +1,190 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:im_app/core/foundation/config.dart'; + +/// 单步诊断状态 +enum DiagStatus { pending, running, pass, fail } + +/// 诊断步骤 +class DiagStep { + final String label; + final DiagStatus status; + final String? detail; + + const DiagStep({ + required this.label, + this.status = DiagStatus.pending, + this.detail, + }); + + DiagStep copyWith({DiagStatus? status, String? detail}) { + return DiagStep( + label: label, + status: status ?? this.status, + detail: detail ?? this.detail, + ); + } +} + +/// 网络诊断页面状态 +class NetworkDiagnosticsState { + final List steps; + final bool isRunning; + final String connectionType; + final String localIp; + + const NetworkDiagnosticsState({ + required this.steps, + this.isRunning = false, + this.connectionType = '未知', + this.localIp = '-', + }); + + NetworkDiagnosticsState copyWith({ + List? steps, + bool? isRunning, + String? connectionType, + String? localIp, + }) { + return NetworkDiagnosticsState( + steps: steps ?? this.steps, + isRunning: isRunning ?? this.isRunning, + connectionType: connectionType ?? this.connectionType, + localIp: localIp ?? this.localIp, + ); + } + + static NetworkDiagnosticsState initial() => const NetworkDiagnosticsState( + steps: [ + DiagStep(label: '网络连通性'), + DiagStep(label: '服务器 TCP 连接'), + DiagStep(label: 'DNS / HTTP 可达性'), + DiagStep(label: 'HTTPS 延迟'), + ], + ); +} + +/// 网络诊断 ViewModel +/// +/// 四步依次执行: +/// 1. 检测网络接口是否连通(dart:io NetworkInterface) +/// 2. TCP connect 到 API host:443 +/// 3. HTTP HEAD 请求(DNS + 连接层) +/// 4. HTTPS GET 完整请求(RTT) +class NetworkDiagnosticsViewModel + extends Notifier { + @override + NetworkDiagnosticsState build() => NetworkDiagnosticsState.initial(); + + Future startDiagnostics() async { + if (state.isRunning) return; + state = NetworkDiagnosticsState.initial().copyWith(isRunning: true); + + await _detectDeviceInfo(); + + // Step 0: 网络连通性 + await _runStep(0, () async { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.any, + ); + if (interfaces.isEmpty) throw Exception('没有可用网络接口'); + return '已连接 (${interfaces.first.name})'; + }); + + // Step 1: TCP 连通 + final host = _extractHost(AppConfig.apiBaseUrl); + await _runStep(1, () async { + final sw = Stopwatch()..start(); + final sock = await Socket.connect( + host, + 443, + timeout: const Duration(seconds: 5), + ); + sw.stop(); + await sock.close(); + return 'RTT ${sw.elapsedMilliseconds}ms'; + }); + + // Step 2: DNS/HTTP HEAD + await _runStep(2, () async { + final sw = Stopwatch()..start(); + final client = HttpClient(); + try { + final req = await client.headUrl( + Uri.parse('${AppConfig.apiBaseUrl}/app/api/health'), + ); + final resp = await req.close(); + sw.stop(); + await resp.drain(); + return 'HTTP ${resp.statusCode} ${sw.elapsedMilliseconds}ms'; + } finally { + client.close(); + } + }); + + // Step 3: HTTPS RTT (GET) + await _runStep(3, () async { + final sw = Stopwatch()..start(); + final client = HttpClient(); + try { + final req = await client.getUrl( + Uri.parse('${AppConfig.apiBaseUrl}/'), + ); + final resp = await req.close(); + sw.stop(); + await resp.drain(); + return 'RTT ${sw.elapsedMilliseconds}ms'; + } finally { + client.close(); + } + }); + + state = state.copyWith(isRunning: false); + } + + Future _detectDeviceInfo() async { + try { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.IPv4, + ); + final addr = interfaces.isEmpty + ? '-' + : interfaces.first.addresses.first.address; + final type = interfaces.isEmpty ? '未知' : interfaces.first.name; + state = state.copyWith(localIp: addr, connectionType: type); + } catch (_) {} + } + + Future _runStep(int index, Future Function() task) async { + _updateStep(index, DiagStatus.running); + try { + final detail = await task(); + _updateStep(index, DiagStatus.pass, detail: detail); + } catch (e) { + _updateStep(index, DiagStatus.fail, detail: e.toString()); + } + } + + void _updateStep(int index, DiagStatus status, {String? detail}) { + final updated = List.from(state.steps); + updated[index] = updated[index].copyWith(status: status, detail: detail); + state = state.copyWith(steps: updated); + } + + String _extractHost(String url) { + try { + return Uri.parse(url).host; + } catch (_) { + return url; + } + } +} + +final networkDiagnosticsViewModelProvider = + NotifierProvider( + NetworkDiagnosticsViewModel.new, +); 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 0e1b59c..9af5a8c 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 @@ -1,30 +1,145 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:im_app/app/di/app_providers.dart'; import 'package:im_app/app/router/app_route_name.dart'; +import 'package:im_app/features/settings/di/settings_providers.dart'; -part 'settings_view_model.g.dart'; +/// 我的页面状态 +class SettingsState { + final String nickname; + final String? avatarUrl; + final String maskedContact; + final int uid; + final bool isLoading; + final bool isLoggingOut; + final String? error; -/// 设置页 ViewModel + const SettingsState({ + this.nickname = '', + this.avatarUrl, + this.maskedContact = '', + this.uid = 0, + this.isLoading = false, + this.isLoggingOut = false, + this.error, + }); + + SettingsState copyWith({ + String? nickname, + String? avatarUrl, + bool clearAvatarUrl = false, + String? maskedContact, + int? uid, + bool? isLoading, + bool? isLoggingOut, + String? error, + bool clearError = false, + }) { + return SettingsState( + nickname: nickname ?? this.nickname, + avatarUrl: clearAvatarUrl ? null : (avatarUrl ?? this.avatarUrl), + maskedContact: maskedContact ?? this.maskedContact, + uid: uid ?? this.uid, + isLoading: isLoading ?? this.isLoading, + isLoggingOut: isLoggingOut ?? this.isLoggingOut, + error: clearError ? null : (error ?? this.error), + ); + } +} + +/// 我的页面 ViewModel +/// +/// 管理个人资料展示、退出登录、子页面导航。 /// /// ## 数据流位置 /// /// ``` /// SettingsPage -/// → ref.read(settingsViewModelProvider.notifier).navigateToTheme(context) -/// → ★ SettingsViewModel.navigateToTheme() ★ ← 你在这里 -/// → context.push(AppRouteName.settingsTheme.path) +/// → ref.watch(settingsViewModelProvider) 读取资料/loading 状态 +/// → ref.read(settingsViewModelProvider.notifier) 调用 logout / navigate* +/// → ★ SettingsViewModel ★ ← 你在这里 +/// → FetchProfileUseCase → GET /app/api/user/profile +/// → LogoutUseCase → POST /app/api/auth/logout + WS + DB +/// → AuthNotifier.logout() → go_router 重定向 /login /// ``` -/// -/// 导航意图由 ViewModel 统一管理,View 不直接调用路由。 -@riverpod -class SettingsViewModel extends _$SettingsViewModel { +class SettingsViewModel extends Notifier { @override - void build() {} + SettingsState build() { + // 延迟加载,避免阻塞首帧 + Future.microtask(loadProfile); + return const SettingsState(); + } + + // ── 资料加载 ──────────────────────────────────────────────────────────────── + + Future loadProfile() async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final profile = await ref.read(fetchProfileUseCaseProvider).execute(); + state = state.copyWith( + isLoading: false, + nickname: profile.nickname, + avatarUrl: profile.profilePic.isEmpty ? null : profile.profilePic, + maskedContact: _maskContact(profile.contact, profile.countryCode), + uid: profile.uid, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + String _maskContact(String contact, String countryCode) { + if (contact.length < 4) return contact; + final visible = contact.substring(contact.length - 4); + final prefix = countryCode.isNotEmpty ? '+$countryCode ' : ''; + return '${prefix}***$visible'; + } + + // ── 退出登录 ──────────────────────────────────────────────────────────────── + + Future logout() async { + state = state.copyWith(isLoggingOut: true, clearError: true); + try { + await ref.read(logoutUseCaseProvider).execute(); + } catch (_) { + // 即使服务端登出失败,仍清除本地状态 + } finally { + // 触发路由守卫跳转至登录页 + ref.read(authNotifierProvider).logout(); + } + } + + // ── 导航 ──────────────────────────────────────────────────────────────────── + + void navigateToEditProfile(BuildContext context) { + context.push(AppRouteName.settingsEditProfile.path); + } - /// 跳转到主题设置页。 void navigateToTheme(BuildContext context) { context.push(AppRouteName.settingsTheme.path); } + + void navigateToBlocklist(BuildContext context) { + context.push(AppRouteName.settingsBlocklist.path); + } + + void navigateToLanguage(BuildContext context) { + context.push(AppRouteName.settingsLanguage.path); + } + + void navigateToNetworkDiagnostics(BuildContext context) { + context.push(AppRouteName.settingsNetworkDiagnostics.path); + } + + void navigateToAbout(BuildContext context) { + context.push(AppRouteName.settingsAbout.path); + } } + +/// 我的页面 ViewModel Provider +final settingsViewModelProvider = + NotifierProvider( + SettingsViewModel.new, +); diff --git a/apps/im_app/lib/features/settings/usecases/fetch_profile_usecase.dart b/apps/im_app/lib/features/settings/usecases/fetch_profile_usecase.dart new file mode 100644 index 0000000..e717aaf --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/fetch_profile_usecase.dart @@ -0,0 +1,33 @@ +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/data/remote/get_profile_request.dart'; + +/// 获取当前用户资料用例 +/// +/// 调用 GET /app/api/user/profile,服务端通过 JWT token 识别当前用户。 +/// +/// ## 数据流位置 +/// +/// ``` +/// SettingsViewModel.loadProfile() +/// → ★ FetchProfileUseCase.execute() ★ ← 你在这里 +/// → NetworksSdkApi.executeRequest(GetProfileRequest()) +/// → 服务端 GET /app/api/user/profile +/// ← ProfileResponse → User +/// ``` +class FetchProfileUseCase { + final NetworksSdkApi _client; + + const FetchProfileUseCase({required NetworksSdkApi client}) : _client = client; + + /// 获取当前登录用户的资料 + /// + /// 抛出 [ApiError] 或 [Exception] 时由调用方处理。 + Future execute() async { + final response = await _client.executeRequest(GetProfileRequest()); + if (response == null) { + throw Exception('获取资料失败:服务端返回空数据'); + } + return response; + } +} diff --git a/apps/im_app/lib/features/settings/usecases/logout_usecase.dart b/apps/im_app/lib/features/settings/usecases/logout_usecase.dart new file mode 100644 index 0000000..0f04aab --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/logout_usecase.dart @@ -0,0 +1,54 @@ +import 'package:storage_sdk/storage_sdk.dart'; + +import 'package:im_app/core/services/socket_manager.dart'; +import 'package:im_app/domain/repositories/auth_repository.dart'; + +/// 退出登录用例 +/// +/// 封装完整登出流程: +/// 1. 调用服务端 /app/api/auth/logout,清除 token +/// 2. 断开 WebSocket 连接 +/// 3. 关闭本地数据库(StorageSdk) +/// +/// AuthNotifier.logout() 由 SettingsViewModel 在 UseCase 完成后调用, +/// 触发 go_router 重定向至登录页。 +/// +/// ## 数据流位置 +/// +/// ``` +/// SettingsViewModel.logout() +/// → ★ LogoutUseCase.execute() ★ ← 你在这里 +/// → AuthRepository.logout() → POST /app/api/auth/logout +/// → SocketManager.disconnect() +/// → StorageSdkLifecycle.closeDatabase() +/// → AuthNotifier.logout() → 路由跳转 /login +/// ``` +class LogoutUseCase { + final AuthRepository _authRepository; + final SocketManager _socketManager; + final StorageSdkApi _storageApi; + + StorageSdkLifecycle get _storageLifecycle => _storageApi as StorageSdkLifecycle; + + const LogoutUseCase({ + required AuthRepository authRepository, + required SocketManager socketManager, + required StorageSdkApi storageApi, + }) : _authRepository = authRepository, + _socketManager = socketManager, + _storageApi = storageApi; + + /// 执行完整登出流程 + /// + /// 抛出异常时,调用方仍应调用 AuthNotifier.logout() 确保本地状态清除。 + Future execute() async { + // 1. 服务端登出(清除 token) + await _authRepository.logout(); + + // 2. 断开 WebSocket + await _socketManager.disconnect(); + + // 3. 关闭本地数据库 + await _storageLifecycle.closeDatabase(); + } +} diff --git a/apps/im_app/lib/features/settings/usecases/update_profile_usecase.dart b/apps/im_app/lib/features/settings/usecases/update_profile_usecase.dart new file mode 100644 index 0000000..e4650a8 --- /dev/null +++ b/apps/im_app/lib/features/settings/usecases/update_profile_usecase.dart @@ -0,0 +1,33 @@ +import 'package:networks_sdk/networks_sdk.dart'; + +import 'package:im_app/data/remote/update_profile_request.dart'; + +/// 更新用户资料用例 +/// +/// ## 数据流位置 +/// +/// ``` +/// EditProfileViewModel.save() +/// → ★ UpdateProfileUseCase.execute() ★ ← 你在这里 +/// → NetworksSdkApi.executeRequest(UpdateProfileRequest) +/// → 服务端 POST /app/api/user/update-profile +/// ``` +class UpdateProfileUseCase { + final NetworksSdkApi _client; + + const UpdateProfileUseCase({required NetworksSdkApi client}) : _client = client; + + Future execute({ + required String nickname, + String? bio, + String? profilePicUrl, + }) async { + await _client.executeRequest( + UpdateProfileRequest( + nickname: nickname, + bio: bio, + profilePic: profilePicUrl, + ), + ); + } +} diff --git a/apps/im_app/lib/features/settings/view/about_page.dart b/apps/im_app/lib/features/settings/view/about_page.dart new file mode 100644 index 0000000..cae0470 --- /dev/null +++ b/apps/im_app/lib/features/settings/view/about_page.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import 'package:im_app/core/foundation/config.dart'; + +/// 关于本应用页 +/// +/// 对应 Gitea issue #13 +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('关于')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // App 图标 + 名称 + const SizedBox(height: 24), + Center( + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.chat_bubble, color: Colors.white, size: 40), + ), + const SizedBox(height: 12), + Text( + 'IM', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '版本 ${AppConfig.appVersion}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + // 链接列表 + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text('服务条款'), + trailing: const Icon(Icons.chevron_right, color: Colors.grey), + onTap: () { + // TODO: 跳转服务条款页面或 WebView + }, + ), + const Divider(height: 1, indent: 52), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('隐私政策'), + trailing: const Icon(Icons.chevron_right, color: Colors.grey), + onTap: () { + // TODO: 跳转隐私政策页面或 WebView + }, + ), + ], + ), + ), + const SizedBox(height: 32), + Center( + child: Text( + '© 2025 IM. All rights reserved.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/im_app/lib/features/settings/view/blocklist_page.dart b/apps/im_app/lib/features/settings/view/blocklist_page.dart new file mode 100644 index 0000000..3d0af8e --- /dev/null +++ b/apps/im_app/lib/features/settings/view/blocklist_page.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// 黑名单管理页 +/// +/// 对应 Gitea issue #10 +/// TODO: 接入 BlocklistViewModel + FetchBlocklistUseCase / UnblockUseCase +class BlocklistPage extends StatelessWidget { + const BlocklistPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('黑名单')), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.block, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + '暂无被拉黑的用户', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } +} diff --git a/apps/im_app/lib/features/settings/view/edit_profile_page.dart b/apps/im_app/lib/features/settings/view/edit_profile_page.dart new file mode 100644 index 0000000..02e6c4c --- /dev/null +++ b/apps/im_app/lib/features/settings/view/edit_profile_page.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:im_app/features/settings/presentation/edit_profile_view_model.dart'; +import 'package:im_app/features/settings/presentation/settings_view_model.dart'; + +/// 编辑个人资料页 +/// +/// 对应 Gitea issue #6 +class EditProfilePage extends ConsumerWidget { + const EditProfilePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(editProfileViewModelProvider); + final vm = ref.read(editProfileViewModelProvider.notifier); + + return Scaffold( + appBar: AppBar( + title: const Text('编辑资料'), + actions: [ + if (state.isSaving) + const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + TextButton( + onPressed: () async { + final ok = await vm.save(); + if (ok && context.mounted) { + // 刷新我的页面资料卡 + ref.read(settingsViewModelProvider.notifier).loadProfile(); + Navigator.of(context).pop(); + } + }, + child: const Text('保存'), + ), + ], + ), + body: state.isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + // 头像区域 + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 44, + backgroundImage: state.avatarUrl != null + ? NetworkImage(state.avatarUrl!) + : null, + child: state.avatarUrl == null + ? const Icon(Icons.person, size: 40) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: const Icon(Icons.camera_alt, size: 16, color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: () { + // TODO Issue #6: 头像上传(CDN 流程) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('头像上传功能开发中')), + ); + }, + child: const Text('更换头像'), + ), + ), + const SizedBox(height: 24), + // 昵称 + TextFormField( + initialValue: state.nickname, + decoration: const InputDecoration( + labelText: '昵称', + border: OutlineInputBorder(), + counterText: '', + ), + maxLength: 50, + onChanged: vm.updateNickname, + ), + const SizedBox(height: 16), + // 个人简介 + TextFormField( + initialValue: state.bio, + decoration: const InputDecoration( + labelText: '个人简介', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 3, + maxLength: 200, + onChanged: vm.updateBio, + ), + if (state.error != null) ...[ + const SizedBox(height: 12), + Text( + state.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ); + } +} diff --git a/apps/im_app/lib/features/settings/view/language_page.dart b/apps/im_app/lib/features/settings/view/language_page.dart new file mode 100644 index 0000000..3690190 --- /dev/null +++ b/apps/im_app/lib/features/settings/view/language_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// 语言设置页 +/// +/// 对应 Gitea issue #9 +/// TODO: 接入 l10n_sdk,实现语言切换持久化 +class LanguagePage extends StatefulWidget { + const LanguagePage({super.key}); + + @override + State createState() => _LanguagePageState(); +} + +class _LanguagePageState extends State { + /// TODO: 从 l10n_sdk / Locale 读取当前语言 + String _selected = 'zh'; + + static const _languages = [ + _LangOption(code: 'zh', label: '简体中文', nativeLabel: '简体中文'), + _LangOption(code: 'en', label: '英文', nativeLabel: 'English'), + _LangOption(code: 'zh-TW', label: '繁体中文', nativeLabel: '繁體中文'), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('语言')), + body: ListView.separated( + itemCount: _languages.length, + separatorBuilder: (_, __) => const Divider(height: 1, indent: 16), + itemBuilder: (context, index) { + final lang = _languages[index]; + final isSelected = _selected == lang.code; + return ListTile( + title: Text(lang.nativeLabel), + subtitle: lang.label != lang.nativeLabel ? Text(lang.label) : null, + trailing: isSelected + ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) + : null, + onTap: () { + setState(() => _selected = lang.code); + // TODO: ref.read(localeProvider.notifier).setLocale(lang.code) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('语言已切换为 ${lang.nativeLabel}(持久化待接入)')), + ); + }, + ); + }, + ), + ); + } +} + +class _LangOption { + const _LangOption({ + required this.code, + required this.label, + required this.nativeLabel, + }); + + final String code; + final String label; + final String nativeLabel; +} diff --git a/apps/im_app/lib/features/settings/view/network_diagnostics_page.dart b/apps/im_app/lib/features/settings/view/network_diagnostics_page.dart new file mode 100644 index 0000000..4867427 --- /dev/null +++ b/apps/im_app/lib/features/settings/view/network_diagnostics_page.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:im_app/features/settings/presentation/network_diagnostics_view_model.dart'; + +/// 网络诊断页 +/// +/// 对应 Gitea issue #12 +class NetworkDiagnosticsPage extends ConsumerWidget { + const NetworkDiagnosticsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(networkDiagnosticsViewModelProvider); + final vm = ref.read(networkDiagnosticsViewModelProvider.notifier); + + return Scaffold( + appBar: AppBar(title: const Text('网络诊断')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // 设备信息 + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '设备信息', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _InfoRow(label: '连接类型', value: state.connectionType), + _InfoRow(label: '本地 IP', value: state.localIp), + ], + ), + ), + ), + const SizedBox(height: 16), + // 诊断步骤 + Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + for (int i = 0; i < state.steps.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 16, + color: Theme.of(context).dividerColor.withOpacity(0.5), + ), + _DiagStepTile(step: state.steps[i]), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: state.isRunning ? null : vm.startDiagnostics, + icon: state.isRunning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Icon(Icons.play_arrow), + label: Text(state.isRunning ? '诊断中…' : '开始诊断'), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const Spacer(), + Text(value, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); + } +} + +class _DiagStepTile extends StatelessWidget { + const _DiagStepTile({required this.step}); + + final DiagStep step; + + @override + Widget build(BuildContext context) { + Widget trailing; + switch (step.status) { + case DiagStatus.pending: + trailing = Icon(Icons.radio_button_unchecked, color: Colors.grey.shade400); + case DiagStatus.running: + trailing = const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ); + case DiagStatus.pass: + trailing = const Icon(Icons.check_circle, color: Colors.green); + case DiagStatus.fail: + trailing = const Icon(Icons.cancel, color: Colors.red); + } + + return ListTile( + dense: true, + title: Text(step.label), + subtitle: step.detail != null ? Text(step.detail!) : null, + trailing: trailing, + ); + } +} 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 d3e3553..3bc29b7 100644 --- a/apps/im_app/lib/features/settings/view/settings_page.dart +++ b/apps/im_app/lib/features/settings/view/settings_page.dart @@ -3,29 +3,362 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart'; -/// 设置页 +/// 我的页面(原设置页) /// -/// 所有用户操作通过 [SettingsViewModel] 处理,View 不直接调用路由。 +/// 结构: +/// ┌─ 个人资料卡 ──────────────────────────────────────────┐ +/// │ 头像 昵称 │ +/// │ 手机号(掩码) 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( - appBar: AppBar( - title: const Text('设置'), - ), - body: ListView( - children: [ - ListTile( - title: const Text('主题'), - trailing: const Icon(Icons.chevron_right), - onTap: () => ref - .read(settingsViewModelProvider.notifier) - .navigateToTheme(context), + 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 _confirmLogout(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + 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)), + ), + ); + } }