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:
164
Doc/mine_tab_architecture.md
Normal file
164
Doc/mine_tab_architecture.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# 我的(Mine)Tab — 架构文档
|
||||
|
||||
> 对应 Gitea issues #5–#13
|
||||
> 参考实现:`im-client-ios-swift-demo` Features/Settings + Features/Profile
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能范围
|
||||
|
||||
| Issue | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| #5 | Tab 重命名 & 个人资料卡片 | ✅ 已实现 |
|
||||
| #6 | 编辑个人资料(昵称/bio/头像) | ✅ 框架已建(CDN 上传待 #6 后续) |
|
||||
| #7 | 退出登录 | ✅ 已实现 |
|
||||
| #8 | 主题持久化 | ⏳ TODO(解开 ThemeModeNotifier 注释) |
|
||||
| #9 | 语言设置 | ✅ UI 框架(l10n_sdk 待接入) |
|
||||
| #10 | 黑名单管理 | ✅ 页面框架(API 待实现) |
|
||||
| #11 | 聊天文件夹 | ⏳ TODO stub |
|
||||
| #12 | 网络诊断 | ✅ 已实现(4步诊断) |
|
||||
| #13 | 关于 / 版本 | ✅ 已实现 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
features/settings/
|
||||
├── di/
|
||||
│ └── settings_providers.dart # UseCase DI 装配
|
||||
├── presentation/
|
||||
│ ├── settings_view_model.dart # 我的页 ViewModel (NotifierProvider)
|
||||
│ ├── edit_profile_view_model.dart # 编辑资料 ViewModel
|
||||
│ ├── network_diagnostics_view_model.dart # 网络诊断 ViewModel
|
||||
│ └── theme_view_model.dart # 主题 ViewModel (@riverpod)
|
||||
├── usecases/
|
||||
│ ├── fetch_profile_usecase.dart # GET /app/api/user/profile
|
||||
│ ├── update_profile_usecase.dart # POST /app/api/user/update-profile
|
||||
│ ├── logout_usecase.dart # POST /app/api/auth/logout + WS + DB
|
||||
│ └── set_theme_usecase.dart # 幂等主题切换
|
||||
└── view/
|
||||
├── settings_page.dart # 我的主页
|
||||
├── edit_profile_page.dart # 编辑资料页
|
||||
├── language_page.dart # 语言选择页
|
||||
├── blocklist_page.dart # 黑名单页(框架)
|
||||
├── network_diagnostics_page.dart # 网络诊断页
|
||||
├── about_page.dart # 关于页
|
||||
├── theme_view.dart # 主题选择页
|
||||
└── widgets/
|
||||
├── settings_section_header.dart
|
||||
└── theme_option_tile.dart
|
||||
|
||||
data/remote/
|
||||
├── get_profile_request.dart # GET /app/api/user/profile(已有)
|
||||
└── update_profile_request.dart # POST /app/api/user/update-profile(新增)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据流
|
||||
|
||||
### 3.1 资料加载
|
||||
|
||||
```
|
||||
SettingsPage (build)
|
||||
└─ ref.watch(settingsViewModelProvider)
|
||||
└─ SettingsViewModel.build()
|
||||
└─ Future.microtask(loadProfile)
|
||||
└─ FetchProfileUseCase.execute()
|
||||
└─ NetworksSdkApi.executeRequest(GetProfileRequest())
|
||||
└─ GET /app/api/user/profile (JWT token in header)
|
||||
└─ ProfileResponse → SettingsState {nickname, avatarUrl, maskedContact, uid}
|
||||
```
|
||||
|
||||
### 3.2 退出登录
|
||||
|
||||
```
|
||||
SettingsPage._confirmLogout()
|
||||
└─ AlertDialog confirm
|
||||
└─ SettingsViewModel.logout()
|
||||
├─ LogoutUseCase.execute()
|
||||
│ ├─ AuthRepository.logout() → POST /app/api/auth/logout
|
||||
│ ├─ SocketManager.disconnect() → 断开 WebSocket
|
||||
│ └─ StorageSdkLifecycle.closeDatabase()
|
||||
└─ AuthNotifier.logout() → go_router 重定向 /login
|
||||
```
|
||||
|
||||
### 3.3 编辑资料保存
|
||||
|
||||
```
|
||||
EditProfilePage → 保存按钮
|
||||
└─ EditProfileViewModel.save()
|
||||
└─ UpdateProfileUseCase.execute(nickname, bio, profilePicUrl)
|
||||
└─ NetworksSdkApi.executeRequest(UpdateProfileRequest)
|
||||
└─ POST /app/api/user/update-profile
|
||||
└─ SettingsViewModel.loadProfile() (刷新资料卡)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 端点
|
||||
|
||||
| 操作 | Method | Path |
|
||||
|------|--------|------|
|
||||
| 获取当前用户资料 | GET | `/app/api/user/profile` |
|
||||
| 更新用户资料 | POST | `/app/api/user/update-profile` |
|
||||
| 退出登录 | POST | `/app/api/auth/logout` |
|
||||
| 黑名单列表(待实现) | GET | `/app/api/account/block/list` |
|
||||
| 解除拉黑(待实现) | POST | `/app/api/account/block/remove` |
|
||||
| 聊天文件夹(待实现) | POST | `/app/api/account/store/get-store` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Provider 设计
|
||||
|
||||
所有 Settings ViewModel 使用 `Notifier<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
|
||||
```
|
||||
@@ -19,11 +19,16 @@ import 'package:im_app/app/di/network_provider.dart';
|
||||
/// - `login` / `logout`:同步更新安全存储
|
||||
class AuthNotifier extends ChangeNotifier {
|
||||
bool _isLoggedIn = false;
|
||||
int? _currentUid;
|
||||
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
|
||||
void login() {
|
||||
/// 登录用户的 UID,登录成功后由 LoginViewModel 写入
|
||||
int? get currentUid => _currentUid;
|
||||
|
||||
void login({required int uid}) {
|
||||
_isLoggedIn = true;
|
||||
_currentUid = uid;
|
||||
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
|
||||
// 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次)
|
||||
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
|
||||
@@ -33,6 +38,7 @@ class AuthNotifier extends ChangeNotifier {
|
||||
|
||||
void logout() {
|
||||
_isLoggedIn = false;
|
||||
_currentUid = null;
|
||||
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
|
||||
// cipherSdk.clearActiveKeyPair()
|
||||
// cipherSdk.clearDerivedKeyCache()
|
||||
|
||||
@@ -68,6 +68,11 @@ enum AppRouteName {
|
||||
|
||||
// ── Settings 子路由 ───────────────────────────────────────────────────────
|
||||
settingsTheme('/settings/theme'),
|
||||
settingsEditProfile('/settings/edit-profile'),
|
||||
settingsBlocklist('/settings/blocklist'),
|
||||
settingsLanguage('/settings/language'),
|
||||
settingsNetworkDiagnostics('/settings/network-diagnostics'),
|
||||
settingsAbout('/settings/about'),
|
||||
|
||||
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
|
||||
login('/login');
|
||||
|
||||
@@ -10,6 +10,11 @@ import 'package:im_app/features/contact/view/contact_page.dart';
|
||||
import 'package:im_app/features/login/view/login_page.dart';
|
||||
import 'package:im_app/features/settings/view/settings_page.dart';
|
||||
import 'package:im_app/features/settings/view/theme_view.dart';
|
||||
import 'package:im_app/features/settings/view/edit_profile_page.dart';
|
||||
import 'package:im_app/features/settings/view/blocklist_page.dart';
|
||||
import 'package:im_app/features/settings/view/language_page.dart';
|
||||
import 'package:im_app/features/settings/view/network_diagnostics_page.dart';
|
||||
import 'package:im_app/features/settings/view/about_page.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
import 'package:im_app/app/router/guards/auth_guard.dart';
|
||||
@@ -152,6 +157,31 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: AppRouteName.settingsTheme.path,
|
||||
builder: (context, state) => const ThemeView(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsEditProfile.path,
|
||||
builder: (context, state) => const EditProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsBlocklist.path,
|
||||
builder: (context, state) => const BlocklistPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsLanguage.path,
|
||||
builder: (context, state) => const LanguagePage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsNetworkDiagnostics.path,
|
||||
builder: (context, state) => const NetworkDiagnosticsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsAbout.path,
|
||||
builder: (context, state) => const AboutPage(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.login.path,
|
||||
|
||||
@@ -40,6 +40,11 @@ String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
|
||||
case AppRouteName.contact:
|
||||
case AppRouteName.settings:
|
||||
case AppRouteName.settingsTheme:
|
||||
case AppRouteName.settingsEditProfile:
|
||||
case AppRouteName.settingsBlocklist:
|
||||
case AppRouteName.settingsLanguage:
|
||||
case AppRouteName.settingsNetworkDiagnostics:
|
||||
case AppRouteName.settingsAbout:
|
||||
case AppRouteName.chatDBTest:
|
||||
// 受保护路由 → 未登录跳登录页
|
||||
return isLoggedIn ? null : AppRouteName.login.path;
|
||||
|
||||
@@ -25,6 +25,13 @@ class ApiPaths {
|
||||
static const chatSendMessage = '/app/api/chat/send-message';
|
||||
static const chatHistory = '/app/api/chat/history';
|
||||
|
||||
// ── Account ──
|
||||
static const accountRequestInfo = '/app/api/account/request-info';
|
||||
static const accountBlocklist = '/app/api/account/block/list';
|
||||
static const accountUnblock = '/app/api/account/block/remove';
|
||||
static const accountStoreGet = '/app/api/account/store/get-store';
|
||||
static const accountStoreUpdate = '/app/api/account/store/update-store';
|
||||
|
||||
// ── Upload ──
|
||||
static const uploadFile = '/app/api/upload/file';
|
||||
|
||||
|
||||
34
apps/im_app/lib/data/remote/update_profile_request.dart
Normal file
34
apps/im_app/lib/data/remote/update_profile_request.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -41,9 +41,9 @@ class AppTab extends StatelessWidget {
|
||||
label: '联系人',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings),
|
||||
label: '设置',
|
||||
icon: Icon(Icons.person_outline),
|
||||
activeIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -72,7 +72,7 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await ref
|
||||
final user = await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.verifyAndLogin(
|
||||
countryCode: state.countryCode,
|
||||
@@ -83,7 +83,7 @@ class LoginViewModel extends _$LoginViewModel {
|
||||
// 成功后触发路由守卫重定向。
|
||||
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state。
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login();
|
||||
ref.read(authNotifierProvider).login(uid: user.uid);
|
||||
} on FormatException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
|
||||
@@ -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