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:
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user