feat(mine): 我的 Tab 全量实现 (#5~#13)

从 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>
This commit is contained in:
pp-bot
2026-03-23 17:20:51 +09:00
parent 33c31b87ac
commit aeeda6f059
22 changed files with 1621 additions and 37 deletions

View File

@@ -0,0 +1,164 @@
# 我的MineTab — 架构文档
> 对应 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<T>` + 手动 `NotifierProvider`**不使用 `@riverpod` 代码生成**,避免额外 build_runner 依赖:
```dart
class SettingsViewModel extends Notifier<SettingsState> { ... }
final settingsViewModelProvider = NotifierProvider<SettingsViewModel, SettingsState>(
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
```

View File

@@ -19,11 +19,16 @@ import 'package:im_app/app/di/network_provider.dart';
/// - `login` / `logout`:同步更新安全存储 /// - `login` / `logout`:同步更新安全存储
class AuthNotifier extends ChangeNotifier { class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false; bool _isLoggedIn = false;
int? _currentUid;
bool get isLoggedIn => _isLoggedIn; bool get isLoggedIn => _isLoggedIn;
void login() { /// 登录用户的 UID登录成功后由 LoginViewModel 写入
int? get currentUid => _currentUid;
void login({required int uid}) {
_isLoggedIn = true; _isLoggedIn = true;
_currentUid = uid;
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入: // TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
// 1. 从安全存储keychain / secure storage读取公私钥对只读一次 // 1. 从安全存储keychain / secure storage读取公私钥对只读一次
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem) // 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
@@ -33,6 +38,7 @@ class AuthNotifier extends ChangeNotifier {
void logout() { void logout() {
_isLoggedIn = false; _isLoggedIn = false;
_currentUid = null;
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥: // TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
// cipherSdk.clearActiveKeyPair() // cipherSdk.clearActiveKeyPair()
// cipherSdk.clearDerivedKeyCache() // cipherSdk.clearDerivedKeyCache()

View File

@@ -68,6 +68,11 @@ enum AppRouteName {
// ── Settings 子路由 ─────────────────────────────────────────────────────── // ── Settings 子路由 ───────────────────────────────────────────────────────
settingsTheme('/settings/theme'), settingsTheme('/settings/theme'),
settingsEditProfile('/settings/edit-profile'),
settingsBlocklist('/settings/blocklist'),
settingsLanguage('/settings/language'),
settingsNetworkDiagnostics('/settings/network-diagnostics'),
settingsAbout('/settings/about'),
// ── 全屏页面(无底部导航栏)────────────────────────────────────────────── // ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
login('/login'); login('/login');

View File

@@ -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/login/view/login_page.dart';
import 'package:im_app/features/settings/view/settings_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/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/di/app_providers.dart';
import 'package:im_app/app/router/app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
import 'package:im_app/app/router/guards/auth_guard.dart'; import 'package:im_app/app/router/guards/auth_guard.dart';
@@ -152,6 +157,31 @@ final routerProvider = Provider<GoRouter>((ref) {
path: AppRouteName.settingsTheme.path, path: AppRouteName.settingsTheme.path,
builder: (context, state) => const ThemeView(), 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( GoRoute(
parentNavigatorKey: _rootKey, parentNavigatorKey: _rootKey,
path: AppRouteName.login.path, path: AppRouteName.login.path,

View File

@@ -40,6 +40,11 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
case AppRouteName.contact: case AppRouteName.contact:
case AppRouteName.settings: case AppRouteName.settings:
case AppRouteName.settingsTheme: case AppRouteName.settingsTheme:
case AppRouteName.settingsEditProfile:
case AppRouteName.settingsBlocklist:
case AppRouteName.settingsLanguage:
case AppRouteName.settingsNetworkDiagnostics:
case AppRouteName.settingsAbout:
case AppRouteName.chatDBTest: case AppRouteName.chatDBTest:
// 受保护路由 → 未登录跳登录页 // 受保护路由 → 未登录跳登录页
return isLoggedIn ? null : AppRouteName.login.path; return isLoggedIn ? null : AppRouteName.login.path;

View File

@@ -25,6 +25,13 @@ class ApiPaths {
static const chatSendMessage = '/app/api/chat/send-message'; static const chatSendMessage = '/app/api/chat/send-message';
static const chatHistory = '/app/api/chat/history'; 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 ── // ── Upload ──
static const uploadFile = '/app/api/upload/file'; static const uploadFile = '/app/api/upload/file';

View File

@@ -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<void>
with _$UpdateProfileRequestApi {
final String nickname;
final String? bio;
@JsonKey(name: 'profile_pic')
final String? profilePic;
UpdateProfileRequest({
required this.nickname,
this.bio,
this.profilePic,
});
}

View File

@@ -41,9 +41,9 @@ class AppTab extends StatelessWidget {
label: '联系人', label: '联系人',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined), icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.settings), activeIcon: Icon(Icons.person),
label: '设置', label: '我的',
), ),
], ],
), ),

View File

@@ -72,7 +72,7 @@ class LoginViewModel extends _$LoginViewModel {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
try { try {
await ref final user = await ref
.read(loginUseCaseProvider) .read(loginUseCaseProvider)
.verifyAndLogin( .verifyAndLogin(
countryCode: state.countryCode, countryCode: state.countryCode,
@@ -83,7 +83,7 @@ class LoginViewModel extends _$LoginViewModel {
// 成功后触发路由守卫重定向。 // 成功后触发路由守卫重定向。
// 注意login() 触发导航后 provider 随即被 dispose之后不能再写 state。 // 注意login() 触发导航后 provider 随即被 dispose之后不能再写 state。
if (!ref.mounted) return; if (!ref.mounted) return;
ref.read(authNotifierProvider).login(); ref.read(authNotifierProvider).login(uid: user.uid);
} on FormatException catch (e) { } on FormatException catch (e) {
if (!ref.mounted) return; if (!ref.mounted) return;
state = state.copyWith(error: e.message, isLoading: false); state = state.copyWith(error: e.message, isLoading: false);

View File

@@ -1,21 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/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 装配 /// Settings feature DI 装配
/// ///
/// 手动装配 UseCase ProviderViewModel 通过此处获取依赖。
///
/// ``` /// ```
/// SettingsViewModel
/// → fetchProfileUseCaseProvider → GET /app/api/user/profile
/// → logoutUseCaseProvider → POST /app/api/auth/logout + WS + DB
///
/// EditProfileViewModel
/// → updateProfileUseCaseProvider → POST /app/api/user/update-profile
///
/// ThemeViewModel /// ThemeViewModel
/// → ref.read(setThemeUseCaseProvider) ← 此处装配 /// → setThemeUseCaseProvider → ThemeModeNotifier内存 + 持久化 TODO
/// → SetThemeUseCase幂等校验
/// → onApply → ThemeModeNotifier.setMode()(内存 + 持久化 TODO
/// ``` /// ```
// ── UseCase ─────────────────────────────────────────────────────────────────── // ── Theme ─────────────────────────────────────────────────────────────────────
/// 设置主题用例 Provider /// 设置主题用例 Provider
final setThemeUseCaseProvider = Provider<SetThemeUseCase>( final setThemeUseCaseProvider = Provider<SetThemeUseCase>(
(_) => const SetThemeUseCase(), (_) => const SetThemeUseCase(),
); );
// ── Profile ────────────────────────────────────────────────────────────────────
/// 获取当前用户资料用例 Provider
final fetchProfileUseCaseProvider = Provider<FetchProfileUseCase>((ref) {
return FetchProfileUseCase(client: ref.read(networkSdkApiProvider));
});
/// 更新用户资料用例 Provider
final updateProfileUseCaseProvider = Provider<UpdateProfileUseCase>((ref) {
return UpdateProfileUseCase(client: ref.read(networkSdkApiProvider));
});
// ── Auth ───────────────────────────────────────────────────────────────────────
/// 退出登录用例 Provider
final logoutUseCaseProvider = Provider<LogoutUseCase>((ref) {
return LogoutUseCase(
authRepository: ref.read(authRepositoryProvider),
socketManager: ref.read(socketManagerProvider),
storageApi: ref.read(storageSdkProvider),
);
});

View File

@@ -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<EditProfileState> {
@override
EditProfileState build() {
Future.microtask(_loadCurrentProfile);
return const EditProfileState(isLoading: true);
}
Future<void> _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<bool> 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, EditProfileState>(
EditProfileViewModel.new,
);

View File

@@ -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<DiagStep> 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<DiagStep>? 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<NetworkDiagnosticsState> {
@override
NetworkDiagnosticsState build() => NetworkDiagnosticsState.initial();
Future<void> 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<void>();
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<void>();
return 'RTT ${sw.elapsedMilliseconds}ms';
} finally {
client.close();
}
});
state = state.copyWith(isRunning: false);
}
Future<void> _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<void> _runStep(int index, Future<String> 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<DiagStep>.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, NetworkDiagnosticsState>(
NetworkDiagnosticsViewModel.new,
);

View File

@@ -1,30 +1,145 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/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 /// SettingsPage
/// → ref.read(settingsViewModelProvider.notifier).navigateToTheme(context) /// → ref.watch(settingsViewModelProvider) 读取资料/loading 状态
/// → ★ SettingsViewModel.navigateToTheme() ★ ← 你在这里 /// → ref.read(settingsViewModelProvider.notifier) 调用 logout / navigate*
/// → context.push(AppRouteName.settingsTheme.path) /// → ★ SettingsViewModel ★ ← 你在这里
/// → FetchProfileUseCase → GET /app/api/user/profile
/// → LogoutUseCase → POST /app/api/auth/logout + WS + DB
/// → AuthNotifier.logout() → go_router 重定向 /login
/// ``` /// ```
/// class SettingsViewModel extends Notifier<SettingsState> {
/// 导航意图由 ViewModel 统一管理View 不直接调用路由。
@riverpod
class SettingsViewModel extends _$SettingsViewModel {
@override @override
void build() {} SettingsState build() {
// 延迟加载,避免阻塞首帧
Future.microtask(loadProfile);
return const SettingsState();
}
// ── 资料加载 ────────────────────────────────────────────────────────────────
Future<void> 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<void> 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) { void navigateToTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path); 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, SettingsState>(
SettingsViewModel.new,
);

View File

@@ -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<ProfileResponse> execute() async {
final response = await _client.executeRequest(GetProfileRequest());
if (response == null) {
throw Exception('获取资料失败:服务端返回空数据');
}
return response;
}
}

View File

@@ -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<void> execute() async {
// 1. 服务端登出(清除 token
await _authRepository.logout();
// 2. 断开 WebSocket
await _socketManager.disconnect();
// 3. 关闭本地数据库
await _storageLifecycle.closeDatabase();
}
}

View File

@@ -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<void> execute({
required String nickname,
String? bio,
String? profilePicUrl,
}) async {
await _client.executeRequest(
UpdateProfileRequest(
nickname: nickname,
bio: bio,
profilePic: profilePicUrl,
),
);
}
}

View File

@@ -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,
),
),
),
],
),
);
}
}

View File

@@ -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),
),
],
),
),
);
}
}

View File

@@ -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),
),
],
],
),
);
}
}

View File

@@ -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<LanguagePage> createState() => _LanguagePageState();
}
class _LanguagePageState extends State<LanguagePage> {
/// 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;
}

View File

@@ -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,
);
}
}

View File

@@ -3,29 +3,362 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/settings/presentation/settings_view_model.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart';
/// 设置页 /// 我的页面(原设置页
/// ///
/// 所有用户操作通过 [SettingsViewModel] 处理View 不直接调用路由。 /// 结构:
/// ┌─ 个人资料卡 ──────────────────────────────────────────┐
/// │ 头像 昵称 │
/// │ 手机号(掩码) UID: xxx │
/// └──────────────────────────────────────────────────────┘
/// 偏好设置 → 主题 / 语言 / 通知
/// 工具 → 黑名单 / 网络诊断
/// 关于 → 关于本应用
/// [退出登录]
class SettingsPage extends ConsumerWidget { class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(settingsViewModelProvider);
final vm = ref.read(settingsViewModelProvider.notifier);
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
title: const Text('设置'), body: CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text('我的'),
automaticallyImplyLeading: false,
), ),
body: ListView( 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: [ children: [
ListTile( _SectionLabel('偏好设置'),
title: const Text('主题'), _SettingsCard(
trailing: const Icon(Icons.chevron_right), items: [
onTap: () => ref _RowConfig(
.read(settingsViewModelProvider.notifier) icon: Icons.palette_outlined,
.navigateToTheme(context), 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)),
),
);
}
} }