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:
@@ -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<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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
apps/im_app/lib/features/settings/view/about_page.dart
Normal file
87
apps/im_app/lib/features/settings/view/about_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
apps/im_app/lib/features/settings/view/blocklist_page.dart
Normal file
29
apps/im_app/lib/features/settings/view/blocklist_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
apps/im_app/lib/features/settings/view/edit_profile_page.dart
Normal file
127
apps/im_app/lib/features/settings/view/edit_profile_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
64
apps/im_app/lib/features/settings/view/language_page.dart
Normal file
64
apps/im_app/lib/features/settings/view/language_page.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user