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,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_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<SettingsState> {
@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) {
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,
);