Initial project

This commit is contained in:
Cody
2026-03-06 14:56:17 +08:00
parent 977b627b15
commit bf9e099747
1180 changed files with 50973 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/ui/base/app_theme.dart';
import 'di/app_providers.dart';
import 'di/network_provider.dart';
import 'router/app_router.dart';
/// 应用根组件
///
/// 职责:
/// - 路由配置go_router含登录守卫
/// - 主题配置(亮色 / 暗色 / 跟随系统)
/// - App 生命周期监听(前后台切换 → WebSocket 断连/重连)
/// - 启动初始化([AppInitializer] 两阶段串行队列)
///
/// ## 启动初始化
///
/// 通过 [appInitializerProvider] 声明任务,两阶段串行执行:
/// - Critical首帧前只放必须阻塞的任务
/// - Deferred首帧后不争抢资源、不卡 UI
///
/// 详见 [AppInitializer] 文档。
class IMApp extends ConsumerStatefulWidget {
const IMApp({super.key});
@override
ConsumerState<IMApp> createState() => _IMAppState();
}
class _IMAppState extends ConsumerState<IMApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
ref.read(appInitializerProvider).run();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final socketManager = ref.read(socketManagerProvider);
switch (state) {
case AppLifecycleState.resumed:
socketManager.onEnterForeground();
case AppLifecycleState.paused:
socketManager.onEnterBackground();
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
break;
}
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'IM App', // TODO: 接入国际化
theme: AppTheme.theme,
darkTheme: AppTheme.darkTheme,
themeMode: ref.watch(themeModeProvider),
routerConfig: ref.watch(routerProvider),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void bootstrap() {
WidgetsFlutterBinding.ensureInitialized();
// ProviderScope 包裹全局network_provider 等 Provider 懒加载单例
runApp(
const ProviderScope(
child: IMApp(),
),
);
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/app_initializer.dart';
import 'network_provider.dart';
// ── 认证 ──────────────────────────────────────────────────────────────────────
/// 登录状态管理
///
/// 同时继承 [ChangeNotifier],作为 go_router [GoRouter.refreshListenable] 使用,
/// 登录 / 退出时 go_router 自动重新执行 redirect无需手动触发。
///
/// ## 当前状态
///
/// Demo 实现无持久化。storage_sdk 就绪后替换为:
/// - `build`:从安全存储读取 token有则视为已登录
/// - `login` / `logout`:同步更新安全存储
class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners();
}
void logout() {
_isLoggedIn = false;
notifyListeners();
}
}
/// 登录状态 Provider
///
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier]ChangeNotifier
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
final authNotifierProvider = Provider<AuthNotifier>(
(ref) => AuthNotifier(),
);
// ── 主题 ──────────────────────────────────────────────────────────────────────
/// 主题模式 Notifier — 控制应用全局亮 / 暗主题
///
/// 启动时从持久化存储读取上次保存的主题模式,无则默认跟随系统。
/// 切换时先更新内存状态,再写入持久化存储。
///
/// ## storage_sdk 接入步骤
///
/// 1. 在 `build()` 解开 TODO读取存储值作为初始模式
/// 2. 在 `setMode()` 解开 TODO每次切换后写入存储
/// 3. 若存储接口是异步的,将 `Notifier<ThemeMode>` 改为
/// `AsyncNotifier<ThemeMode>``build()` 改为 `Future<ThemeMode>`
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
// TODO: storage_sdk 就绪后从持久化读取初始值:
// final saved = ref.read(themeStorageProvider).readThemeMode();
// return saved ?? ThemeMode.system;
return ThemeMode.system;
}
void setMode(ThemeMode mode) {
state = mode;
// TODO: storage_sdk 就绪后写入持久化:
// ref.read(themeStorageProvider).saveThemeMode(mode);
}
}
/// 主题模式 Provider
///
/// ## Setting 页切换(只需一行)
///
/// ```dart
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.system);
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.light);
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.dark);
/// ```
///
/// ## 持久化storage_sdk TODO
///
/// 读取和写入的 TODO 均在 [ThemeModeNotifier] 内,接入 storage_sdk 后解开即可。
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(
ThemeModeNotifier.new,
);
// ── 启动初始化 ────────────────────────────────────────────────────────────────
/// AppInitializer Provider
///
/// 集中声明所有启动初始化任务app.dart 只需一行 `.run()`。
///
/// ## 任务分类规则
///
/// 问自己:「这个任务不完成,用户能正常看到首页吗?」
/// - **不能** → 放 critical谨慎每多一个都拖慢启动
/// - **能** → 放 deferred绝大多数情况
///
/// ## 当前任务清单
///
/// | 阶段 | 任务 | 说明 |
/// |---|---|---|
/// | Critical | NetworkMonitor | 后续 HTTP、WebSocket 都依赖网络状态 |
/// | Deferred | (待扩展) | 推送注册、登录态恢复、缓存预热等 |
final appInitializerProvider = Provider<AppInitializer>((ref) {
return AppInitializer(
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'AppInit'}] $message');
},
critical: [
// 网络监听必须最先就绪(后续 HTTP、WebSocket 都依赖它)
InitTask(
name: 'NetworkMonitor',
task: () => ref.read(networkMonitorProvider).initialize(),
),
],
deferred: [
// TODO: 推送注册
// InitTask(
// name: 'PushNotification',
// task: () => ref.read(pushServiceProvider).register(),
// ),
//
// TODO: 登录态恢复(从安全存储读取 token → 自动登录)
// InitTask(
// name: 'AuthRestore',
// task: () => ref.read(authRestoreUseCaseProvider).execute(),
// ),
//
// TODO: 缓存预热
// InitTask(
// name: 'CacheWarmup',
// task: () => ref.read(cacheServiceProvider).warmup(),
// ),
],
);
});

View File

@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../data/local/drift/app_database.dart';
/// 全局单例 StorageSdkApi整个 App 生命周期内唯一实例。
///
/// storage_sdk 负责数据库连接生命周期和 CRUD 机制;
/// im_app 负责 schemaAppDatabase + 各业务表)。
///
/// 用法:
/// ```dart
/// // 登录后开库
/// await ref.read(storageSdkProvider).openDatabase(user.id);
///
/// // CRUD 示例
/// final db = ref.read(storageSdkProvider);
/// await db.insertOrReplace(appDb.users, companion);
/// ```
final storageSdkProvider = Provider<StorageSdkApi>((ref) {
return StorageSdkApi(
databaseFactory: (executor) => AppDatabase(executor),
);
});

View File

@@ -0,0 +1,404 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
import '../../core/foundation/api_paths.dart';
import '../../core/foundation/config.dart';
import '../../core/foundation/constants.dart';
import '../../core/services/network_monitor.dart';
import '../../core/services/socket_manager.dart';
// ── 网络状态监听 ──────────────────────────────────────────────────────────────
/// 网络状态监听 Provider全局单例
///
/// 基于 connectivity_plus 监听平台网络变化,
/// 作为公共服务供多个模块使用:
/// - SocketManager网络变化时自动断连/重连 WebSocket
/// - HTTP 层:请求前检查网络可用性
/// - UI 层:显示网络状态提示
///
/// ## 使用
///
/// ```dart
/// // 查询当前状态
/// final isConnected = ref.read(networkMonitorProvider).isConnected;
///
/// // 监听状态变化
/// ref.listen(networkMonitorProvider, (prev, monitor) {
/// monitor.onStatusChanged.listen((isAvailable) {
/// // 处理网络变化
/// });
/// });
/// ```
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
final monitor = NetworkMonitor(
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
);
ref.onDispose(() {
monitor.dispose();
});
return monitor;
});
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
/// API 配置 Provider全局单例
///
/// 从 [AppConfig.apiBaseUrl]config.json → --dart-define-from-file读取 baseURL
/// 注入到 Network SDK 作为所有 HTTP 请求的基础 URL。
///
/// [onCheckNetworkAvailable] 由 [networkMonitorProvider](公共服务)注入,
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
final apiConfigProvider = Provider<ApiConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
'Platform': 'Android', // TODO: 运行时从平台 API 获取
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
},
tokenExpiredCodes: {30002, 30003, 30124},
forceLogoutCodes: {30125},
onForceLogout: () {
// TODO: 清除登录态,跳转登录页
},
onTokenRefresh: () async {
// TODO: App 层刷新 token 逻辑
return null;
},
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
);
});
/// API 客户端 Provider全局单例
///
/// 含拦截器Auth / Retry / Logging、超时配置。
final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
final config = ref.read(apiConfigProvider);
return NetworksSdkApi()..initialize(config);
});
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
/// SocketConfig Provider全局单例
///
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
/// SDK 内部不调用其他 SDK。
final socketConfigProvider = Provider<SocketConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Socket'}] $message');
},
onCheckNetworkAvailable: () async {
return networkMonitor.isConnected;
},
);
});
/// SocketClient Provider全局单例
///
/// 与 apiClientProvider 对称。
final socketClientProvider = Provider<NetworksMessagingApi>((ref)
{
final config = ref.read(socketConfigProvider);
return NetworksMessagingApi()..initialize(config);
});
/// SocketManager Provider
///
/// 封装连接生命周期、网络/前后台事件响应、操作前置检查、消息预处理。
/// 业务模块通过此 Provider 访问 WebSocket 能力。
///
/// ## 前置检查
///
/// connect / send 前先检查网络可用性 + 后台状态,
/// 无效操作直接跳过,避免无意义的网络请求。
/// 与 HTTP 层 [ApiClient.executeRequest] 的网络前置检查对称。
///
/// ## 事件驱动
///
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
/// 自动触发断连/重连。
///
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
/// 后续接入加解密 SDK 时,在此注入解密回调,
/// SDK 内部不调用其他 SDK。
final socketManagerProvider = Provider<SocketManager>((ref) {
final client = ref.read(socketClientProvider);
final networkMonitor = ref.read(networkMonitorProvider);
final manager = SocketManager(
client: client,
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'SocketManager'}] $message');
},
);
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
});
ref.onDispose(() {
subscription.cancel();
unawaited(manager.dispose());
});
return manager;
});
// ── 辅助 ──────────────────────────────────────────────────────────────────────
/// HTTP baseURL → WebSocket URL 转换
///
/// https://api.example.com → wss://api.example.com/ws
/// http://api.example.com → ws://api.example.com/ws
String _buildWsUrl(String httpBaseUrl) {
String base = httpBaseUrl;
if (base.startsWith('https://')) {
base = base.replaceFirst('https://', 'wss://');
} else if (base.startsWith('http://')) {
base = base.replaceFirst('http://', 'ws://');
}
return '$base${ApiPaths.wsConnect}';
}
// ══════════════════════════════════════════════════════════════════════════════
// 本文件的职责
// ══════════════════════════════════════════════════════════════════════════════
//
// 提供所有网络基础设施 Provider网络监听 + HTTP + WebSocket。
// 业务模块的 DI 链路Repository → UseCase 按需)
// 内聚在 features/{模块}/di/{模块}_providers.dart 中。
//
// di/ 目录只放「需要手动装配的 Provider」构造注入、回调组合等
// ViewModel Provider 由 @riverpod 注解自动生成,不在 di/ 中。
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ DI 架构 │
// ├──────────────────────────────────────────────────────────────────────────┤
// │ app/di/ ← 手动装配SDK 基础设施 │
// │ ├── app_providers.dart → 主题 + 启动初始化 │
// │ └── network_provider.dart → 网络监听 + HTTP + WebSocket │
// │ │
// │ features/{模块}/di/ ← 手动装配:业务模块 DI 链路 │
// │ └── auth_providers.dart → Repository → UseCase按需
// │ chat_providers.dart 每个模块 DI 链路内聚在一个文件 │
// │ │
// │ features/{模块}/presentation/ ← @riverpod 自动生成ViewModel │
// │ └── login_view_model.dart → loginViewModelProvider代码生成
// └──────────────────────────────────────────────────────────────────────────┘
//
// Provider 链路:
//
// networkMonitorProvider公共服务HTTP + WS 共用)
// ├── apiConfigProvider → apiClientProvider ← HTTP 层
// └── socketConfigProvider → socketClientProvider ← WS 层
// → socketManagerProvider
//
// 网络事件驱动链路:
//
// connectivity_plus平台网络事件
// → NetworkMonitor.onStatusChangedtrue / false
// → SocketManager.handleNetworkStatusChanged()
// → 断网: disconnect()
// → 恢复: connect(token: lastToken)
//
// 前后台事件驱动链路:
//
// WidgetsBindingObserverApp 层 app.dart
// → SocketManager.onEnterBackground() → disconnect
// → SocketManager.onEnterForeground() → reconnect
//
// Repository 直接注入 ApiClient通过回调注入其他 SDK 能力:
//
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存network_sdk
// secureStorage.saveToken(token); // 持久化crypto_sdk
// }
//
// 这样 network_sdk 和 crypto_sdk 互不依赖App 层是唯一知道两者的地方。
//
// ══════════════════════════════════════════════════════════════════════════════
// 新增接口的完整流程(以登录为例)
// ══════════════════════════════════════════════════════════════════════════════
//
// 「一个接口 = 一个 Request 文件」,严格按层调用,禁止跳层。
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ 文件 & 职责总览 │
// ├──────────────────────────────────────────────────────────────────────────┤
// │ login_request.dart Request + Response DTO一个端点一个文件
// │ auth_repository_impl.dart executeRequest → DTO → Entity + 回调写 Token│
// │ login_usecase.dart 格式校验 → 调 Repository按需非必须
// │ auth_providers.dart DI 装配Repository → UseCase 按需) │
// │ login_view_model.dart ref.read(authRepositoryProvider).login() │
// │ 或 ref.read(loginUseCaseProvider).execute() │
// │ login_page.dart ref.watch(loginViewModelProvider) │
// └──────────────────────────────────────────────────────────────────────────┘
//
// ─────────────────────────────────────────────────────────────────────────
// Step 1: 定义 Requestdata/remote/login_request.dart
// ─────────────────────────────────────────────────────────────────────────
//
// 一个文件包含两部分Response DTO + Request 类。
//
// @JsonSerializable()
// class LoginData { // Response DTO
// final String token;
// final String userId;
// factory LoginData.fromJson(Map<String, dynamic> json) => _$LoginDataFromJson(json);
// User toEntity() => User(id: userId, ...); // DTO → Domain Entity
// }
//
// @ApiRequest(path: ApiPaths.authLogin, method: HttpMethod.post,
// responseType: LoginData, requestType: ApiRequestType.login)
// @JsonSerializable()
// class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi {
// final String email;
// final String password;
// LoginRequest({required this.email, required this.password});
// @override Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
// }
//
// build_runner 生成 _$LoginRequestApi mixin → 自动提供 path / method / fromJson 注册。
//
// ─────────────────────────────────────────────────────────────────────────
// Step 2: Repositorydata/repositories/auth_repository_impl.dart
// ─────────────────────────────────────────────────────────────────────────
//
// class AuthRepositoryImpl implements AuthRepository {
// final ApiClient _client; // ← 直接注入 ApiClient
// final void Function(String?) _onTokenUpdate; // ← 回调,由 Provider 层组合
//
// Future<User> login({required String email, required String password}) async {
// final dto = await _client.executeRequest(
// LoginRequest(email: email, password: password),
// );
// _onTokenUpdate(dto!.token); // 回调写入 Token
// return dto.toEntity(); // DTO → Domain Entity
// }
// }
//
// ─────────────────────────────────────────────────────────────────────────
// Step 3: Provider 装配 + ViewModel
// ─────────────────────────────────────────────────────────────────────────
//
// // --- Provider 装配features/auth/di/auth_providers.dart ---
//
// // Repository直接注入 ApiClient + 回调组合多个 SDK 能力)
// final authRepositoryProvider = Provider((ref) {
// final apiConfig = ref.read(apiConfigProvider);
// return AuthRepositoryImpl(
// client: ref.read(apiClientProvider), // 直接注入
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存network_sdk
// // secureStorage.saveToken(token); // 持久化crypto_sdk
// },
// );
// });
//
// // UseCase按需 — 登录有多步编排,所以需要)
// final loginUseCaseProvider = Provider((ref) {
// return LoginUseCase(authRepository: ref.read(authRepositoryProvider));
// });
//
// // --- ViewModelfeatures/auth/presentation/login_view_model.dart ---
//
// // 常规写法ViewModel 直接调 Repository
// @riverpod
// class LoginViewModel extends _$LoginViewModel {
// Future<void> login(String email, String password) async {
// state = state.copyWith(isLoading: true);
// try {
// final user = await ref.read(authRepositoryProvider).login(
// email: email, password: password,
// );
// state = state.copyWith(user: user, isLoading: false);
// } on ApiError catch (e) {
// state = state.copyWith(error: e.displayMessage, isLoading: false);
// }
// }
// }
//
// // 进阶写法:有 UseCase 时(格式校验 + 多步编排)
// // final user = await ref.read(loginUseCaseProvider).execute(
// // email: email, password: password,
// // );
//
// ═════════════════════════════════════════════════════════════════════════
// 内部执行链路(点击登录按钮后发生了什么)
// ═════════════════════════════════════════════════════════════════════════
//
// View: vm.login(email, password)
// → ViewModel: ref.read(authRepositoryProvider).login(...) ← 常规路径
// → ViewModel: ref.read(loginUseCaseProvider).execute(...) ← 进阶路径(有 UseCase 时)
// → UseCase: 格式校验(邮箱 + 密码)
// → UseCase/ViewModel: authRepository.login(...)
// → Repository: _client.executeRequest(LoginRequest(...))
// → ApiClient.executeRequest(request)
// 1. 拼 URL: baseURL + "/auth/login"
// 2. request.parameters 触发 fromJson 自动注册
// 3. AuthInterceptor: 注入 token + platform headers
// 4. Dio.request(url, data: {email, password})
// 5. RetryInterceptor: token 过期 → 刷新 → 自动重试
// 6. LoggingInterceptor: 打印请求/响应日志
// → request.decodeResponse(response)
// 1. ApiResponseWrapper.fromJson: 拆 { code, message, data }
// 2. 检查 code != 0 → 抛 ApiError
// 3. fromJsonRegistry[LoginData] → LoginData.fromJson(data)
// → 返回 LoginDataDTO
// → _onTokenUpdate(token) 回调写入 TokenProvider 层组合:内存 + 持久化)
// → LoginData.toEntity() → UserDomain Entity
// → state.copyWith(user: user) 更新状态
// View: ref.watch → 自动 rebuild UI
//
// ═════════════════════════════════════════════════════════════════════════
// 各 HTTP 方法速查 — 新增接口时参照
// ═════════════════════════════════════════════════════════════════════════
//
// GET参数走 URL query string
// @ApiRequest(path: ..., method: HttpMethod.get, responseType: ProfileData)
// class GetProfileRequest extends ApiRequestable<ProfileData> with _$... { }
//
// POST参数走 JSON body
// 见上方 LoginRequest 示例。
//
// DELETE / PUT / PATCH与 POST 相同,只改 method
// @ApiRequest(path: ..., method: HttpMethod.delete, responseType: ...)
//
// POST 无响应数据(如 logout
// class LogoutRequest extends ApiRequestable<void> { ... }
// // → 返回 null
//
// Upload A: FormData 上传到自有后端
// @override Object? get uploadData => FormData.fromMap({ 'file': ... });
//
// Upload B: 二进制上传到 S3 presigned URL
// @override String get path => presignedURL; // 完整 URL不拼 baseURL
// @override Object? get uploadData => bytes; // Uint8List
// @override decodeResponse(response) { ... } // S3 不走标准信封
//

View File

@@ -0,0 +1,97 @@
/// 应用路由枚举
///
/// 每个枚举值对应一个注册路由及其绝对路径。
///
/// ## 为什么用枚举而不是常量类
///
/// `auth_guard.dart` 对路由做 switch 分析Dart 的枚举 switch 是穷举的:
/// 新增路由时若没在 switch 里补 case编译器直接报错而不是等运行时漏掉。
///
/// ## 使用方式
///
/// ```dart
/// // 无参数导航
/// context.push(AppRouteName.settingsTheme.path);
/// context.go(AppRouteName.chat.path);
///
/// // 带参数导航extra 传对象,适合列表点入详情等已有数据的场景)
/// context.push(
/// AppRouteName.chatDetail.path,
/// extra: (conversationId: '42', title: '技术支持'),
/// );
///
/// // 带路径参数导航(路径中内嵌 id适合需要直接链接或分享的场景
/// context.push(AppRouteName.chatDetailByIdPath('99'));
///
/// // 路由表定义
/// GoRoute(path: AppRouteName.chat.path, ...)
/// ```
///
/// ## 注意:子路由 path 是相对路径片段
///
/// go_router 在子路由中使用相对路径片段(不含父路径前缀),
/// 这是框架规定,不是硬编码字符串:
/// ```dart
/// GoRoute(
/// path: AppRouteName.settings.path, // '/settings'
/// routes: [
/// GoRoute(path: AppRouteName.settingsTheme.segment, ...), // 'theme'
/// ],
/// )
/// ```
/// 导航时仍用 `AppRouteName.settingsTheme.path`,与枚举保持一致。
///
/// 子路由声明使用 [segment](相对路径片段),避免在路由表中硬编码字符串:
/// ```dart
/// GoRoute(path: AppRouteName.settingsTheme.segment, ...) // 'theme'
/// ```
///
/// ## 注意:含路径参数的路由
///
/// [chatDetailById] 的 [path] 包含占位符 `:id`,不能直接用于导航。
/// 导航时使用 [chatDetailByIdPath] 传入实际 id
/// ```dart
/// context.push(AppRouteName.chatDetailByIdPath('99'));
/// ```
enum AppRouteName {
// ── Tab 根路由 ────────────────────────────────────────────────────────────
chat('/chat'),
contact('/contact'),
settings('/settings'),
// ── Chat 子路由 ──────────────────────────────────────────────────────────
// extra: ({String conversationId, String title})
chatDetail('/chat/detail'),
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
chatDetailById('/chat/:id'),
// ── Settings 子路由 ───────────────────────────────────────────────────────
settingsTheme('/settings/theme'),
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
login('/login');
const AppRouteName(this.path);
/// 绝对路径,用于 [context.push] / [context.go] 导航及顶层路由表声明
///
/// 注意:[chatDetailById] 的 path 含占位符 `:id`,导航时用 [chatDetailByIdPath]。
final String path;
/// 相对路径片段path 的最后一段),用于 go_router 子路由的 [GoRoute.path] 声明
///
/// 例:`AppRouteName.settingsTheme.segment` → `'theme'`
String get segment => path.split('/').last;
/// 从绝对路径查找枚举值,路径未注册时返回 null
///
/// 注意:含路径参数的路由(如 `/chat/99`)无法匹配,返回 null
/// auth_guard 会按受保护路由处理(未登录重定向到登录页)。
static AppRouteName? fromPath(String path) =>
AppRouteName.values.where((r) => r.path == path).firstOrNull;
/// 生成 [chatDetailById] 的实际导航路径,将 `:id` 替换为真实 id
///
/// 例:`AppRouteName.chatDetailByIdPath('99')` → `'/chat/99'`
static String chatDetailByIdPath(String id) => '/chat/$id';
}

View File

@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/app_tab/view/app_tab.dart';
import '../../features/chat/view/chat_detail_page.dart';
import '../../features/chat/view/chat_page.dart';
import '../../features/contact/view/contact_page.dart';
import '../../features/login/view/login_page.dart';
import '../../features/settings/view/settings_page.dart';
import '../../features/settings/view/theme_view.dart';
import '../di/app_providers.dart';
import 'app_route_name.dart';
import 'guards/auth_guard.dart';
/// 应用路由 Provider
///
/// 路由结构:
/// ```
/// StatefulShellRoute底部导航栏持久容器
/// ├── /chat ChatPage
/// ├── /contact ContactPage
/// └── /settings SettingsPage
///
/// ── 全屏页面无底部导航栏parentNavigatorKey = _rootKey──
/// /chat/detail ChatDetailPageextra 传参)
/// /chat/:id ChatDetailPage路径参数
/// /settings/theme ThemeView
/// /login LoginPage
/// ```
///
/// ## Shell 内 vs Shell 外
///
/// Shell 内路由Tab 根路由)始终显示底部导航栏。
/// Shell 外路由(详情页 / 子功能页无底部导航栏push 进入后有返回按钮。
/// 这与 iOS / Android 主流 IM App 的交互一致(会话详情、设置子页均全屏)。
///
/// ## parentNavigatorKey 的作用
///
/// go_router push 时,路由默认放到"最近的 Navigator 祖先"上。
/// 在 StatefulShellBranch 内 push最近的 Navigator 是 Branch Navigator
/// 而不是 Root NavigatorShell 不会被盖住TabBar 仍然可见。
///
/// 设置 `parentNavigatorKey: _rootKey` 后,路由强制放到 Root Navigator
/// 盖住整个 ShellTabBar 消失,表现为真正的全屏页面。
///
/// ## 登录守卫
///
/// [authGuard] 检查 [AuthNotifier.isLoggedIn],未登录时重定向到 /login。
/// 登录 / 退出后 [AuthNotifier.notifyListeners] 触发 [refreshListenable]
/// go_router 自动重新执行 redirect无需手动跳转。
///
/// ## Tab 状态保持
///
/// [StatefulShellRoute.indexedStack] 为每个 Tab 维护独立的 Navigator 栈,
/// 切换 Tab 时页面状态(滚动位置、输入内容等)不丢失。
// Root Navigator Key供全屏路由声明 parentNavigatorKey确保覆盖整个 Shell
final _rootKey = GlobalKey<NavigatorState>();
final routerProvider = Provider<GoRouter>((ref) {
final authNotifier = ref.read(authNotifierProvider);
return GoRouter(
// Root Navigator 的 Key供全屏路由声明 parentNavigatorKey 使用,
// 确保 push 时覆盖整个 Shell包括 TabBar
navigatorKey: _rootKey,
// 冷启动默认落地页authGuard 会在进入前检查登录状态并按需重定向
initialLocation: AppRouteName.chat.path,
// 在控制台打印每次路由变化,方便开发期间调试;上线前设为 false
debugLogDiagnostics: true,
// 监听 authNotifier 变化:登录 / 退出时自动触发 redirect 重新执行,
// 无需在业务代码里手动 context.go守卫统一负责跳转
refreshListenable: authNotifier,
redirect: (context, state) => authGuard(authNotifier, state),
routes: [
// ── Shell 内:底部导航栏始终可见 ─────────────────────────────────────
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppTab(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: AppRouteName.chat.path,
builder: (context, state) => const ChatPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRouteName.contact.path,
builder: (context, state) => const ContactPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRouteName.settings.path,
builder: (context, state) => const SettingsPage(),
),
],
),
],
),
// ── Shell 外:全屏页面,无底部导航栏 ─────────────────────────────────
// parentNavigatorKey: _rootKey 确保路由覆盖 ShellTabBar 消失
//
// extra 传参:接收 ({String conversationId, String title})
GoRoute(
parentNavigatorKey: _rootKey,
path: AppRouteName.chatDetail.path,
builder: (context, state) {
final extra =
state.extra as ({String conversationId, String title});
return ChatDetailPage(
conversationId: extra.conversationId,
title: extra.title,
);
},
),
// 路径参数id 内嵌在 URL 中(/chat/:id
GoRoute(
parentNavigatorKey: _rootKey,
path: AppRouteName.chatDetailById.path,
builder: (context, state) {
final id = state.pathParameters['id']!;
return ChatDetailPage(
conversationId: id,
title: '路径参数详情',
);
},
),
GoRoute(
parentNavigatorKey: _rootKey,
path: AppRouteName.settingsTheme.path,
builder: (context, state) => const ThemeView(),
),
GoRoute(
parentNavigatorKey: _rootKey,
path: AppRouteName.login.path,
builder: (context, state) => const LoginPage(),
),
],
);
});

View File

@@ -0,0 +1,47 @@
import 'package:go_router/go_router.dart';
import '../../di/app_providers.dart';
import '../app_route_name.dart';
/// 登录守卫
///
/// 在 [GoRouter.redirect] 中调用,返回 null 表示放行,返回路径表示重定向目标。
/// 接收 [AuthNotifier] 而非 [Ref],避免守卫内部依赖 Riverpod便于单测。
///
/// ## 穷举保护
///
/// 使用 [AppRouteName] 枚举 + switch 分析路由权限Dart 编译器保证穷举:
/// 在 [AppRouteName] 新增枚举值后,此处 switch 未补 case 则编译报错。
///
/// ## 路由权限规则
///
/// | 路由 | 未登录 | 已登录 |
/// |------|--------|--------|
/// | login | 放行 | 重定向 → chat |
/// | 其余 | 重定向 → login | 放行 |
///
/// ## storage_sdk 接入后
///
/// 将 [AuthNotifier] 内的 Demo 状态替换为持久化 token守卫本身无需改动。
String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
final isLoggedIn = authNotifier.isLoggedIn;
final route = AppRouteName.fromPath(state.matchedLocation);
// 路径不在枚举中(理论上不应出现)→ 按受保护处理
if (route == null) return isLoggedIn ? null : AppRouteName.login.path;
switch (route) {
case AppRouteName.login:
// 已登录还在登录页 → 跳聊天页
return isLoggedIn ? AppRouteName.chat.path : null;
case AppRouteName.chat:
case AppRouteName.chatDetail:
case AppRouteName.chatDetailById:
case AppRouteName.contact:
case AppRouteName.settings:
case AppRouteName.settingsTheme:
// 受保护路由 → 未登录跳登录页
return isLoggedIn ? null : AppRouteName.login.path;
}
}

View File

@@ -0,0 +1,31 @@
/// API 路径常量 — 全局统一管理
///
/// 所有 HTTP 接口路径在此定义,`@ApiRequest(path: ApiPaths.xxx)` 引用。
/// 集中管理便于:搜索、重命名、接口迁移、后端对齐。
///
/// 命名规则:`模块_操作`,如 `authLogin`、`chatSendMessage`。
///
/// 新增路径时,先 Cmd+F 搜索路径值,确认不重复后再添加。
// ignore: avoid_classes_with_only_static_members
class ApiPaths {
ApiPaths._();
// ── Auth ──
static const authLogin = '/auth/login';
static const authRefreshToken = '/auth/refresh-token';
static const authLogout = '/auth/logout';
// ── User ──
static const userProfile = '/user/profile';
static const userUpdateProfile = '/user/update-profile';
// ── Chat ──
static const chatSendMessage = '/chat/send-message';
static const chatHistory = '/chat/history';
// ── Upload ──
static const uploadFile = '/upload/file';
// ── WebSocket ──
static const wsConnect = '/ws';
}

View File

@@ -0,0 +1,14 @@
// 编译期从 --dart-define-from-file=config/config.json 注入
// CI 打包时脚本修改 config.json 写入线上值本地开发保持默认IS_DEV=true
// ignore: avoid_classes_with_only_static_members
class AppConfig {
AppConfig._();
static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true);
static const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://dev-api.example.com',
);
static bool get isProd => !isDev;
}

View File

@@ -0,0 +1,17 @@
/// 全局常量
///
/// 跨模块共用的配置值集中管理,避免散落在各处导致不一致。
class AppConstants {
AppConstants._();
// ── 网络重试 ──
/// 最大重试次数HTTP 瞬态错误 + WebSocket 重连 统一)
static const maxRetries = 3;
/// 重试基础延迟(指数退避起点)
static const retryBaseDelay = Duration(seconds: 1);
/// 重连最大延迟(指数退避上限)
static const maxReconnectDelay = Duration(seconds: 30);
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/widgets.dart';
/// App 启动初始化器
///
/// 两阶段串行队列,确保启动流畅、资源不竞争。
///
/// ## 为什么不能在 initState 里一股脑 await
///
/// 初始化任务并发执行会导致:
/// - 多个 await 阻塞首帧渲染 → 白屏时间长
/// - 任务间资源竞争网络、IO、CPU→ 互相拖慢
/// - 一个任务失败可能阻塞后续所有任务
///
/// ## 解决方案:两阶段 + 串行队列
///
/// ```
/// App 启动
/// │
/// ├── Phase 1: CriticalinitState 中同步触发)
/// │ 串行执行,必须在用户交互前完成。
/// │ 只放真正阻塞用户操作的任务(尽量少)。
/// │ 例:网络监听(后续所有网络操作依赖它)
/// │
/// ├── 首帧渲染(用户看到 UI
/// │
/// └── Phase 2: DeferredaddPostFrameCallback 触发)
/// 串行执行,首帧渲染后逐个跑。
/// 不争抢资源,不影响 UI 流畅度。
/// 例:推送注册、缓存预热、埋点 SDK 初始化
/// ```
///
/// ## 添加新任务的规则
///
/// 问自己:「这个任务不完成,用户能正常看到首页吗?」
/// - **能** → 放 deferred绝大多数情况
/// - **不能** → 放 critical谨慎添加每多一个都会拖慢启动
///
/// ## 设计原则
///
/// - **串行不并发**:同阶段内任务按顺序逐个执行,避免资源竞争
/// - **隔离不传染**:每个任务独立 try-catch一个失败不阻塞后续
/// - **可观测**:每个任务计时 + 日志,方便排查启动瓶颈
/// - **首帧优先**deferred 阶段等首帧渲染完再开始
class AppInitializer {
/// 关键任务(首帧前串行执行)
final List<InitTask> critical;
/// 延迟任务(首帧后串行执行)
final List<InitTask> deferred;
/// 日志回调
final void Function(String message, {String? tag})? onLog;
const AppInitializer({
this.critical = const [],
this.deferred = const [],
this.onLog,
});
/// 启动初始化
///
/// 1. 立即串行执行 critical 任务
/// 2. 注册 addPostFrameCallback首帧后串行执行 deferred 任务
///
/// 在 initState 中调用fire-and-forget不 await
void run() {
_runCritical();
}
/// 执行关键阶段
Future<void> _runCritical() async {
if (critical.isEmpty) {
_log('No critical tasks');
} else {
_log('── Critical phase: ${critical.length} task(s) ──');
final totalSw = Stopwatch()..start();
await _runTasksSequentially(critical);
totalSw.stop();
_log('── Critical phase done in ${totalSw.elapsedMilliseconds}ms ──');
}
// 关键阶段完成后,注册首帧回调执行延迟阶段
_scheduleDeferredPhase();
}
/// 注册 addPostFrameCallback
///
/// 需要在 critical 完成后再注册,确保顺序:
/// critical 完成 → 首帧渲染 → deferred 开始
void _scheduleDeferredPhase() {
if (deferred.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _runDeferred());
}
/// 执行延迟阶段
Future<void> _runDeferred() async {
_log('── Deferred phase: ${deferred.length} task(s) ──');
final totalSw = Stopwatch()..start();
await _runTasksSequentially(deferred);
totalSw.stop();
_log('── Deferred phase done in ${totalSw.elapsedMilliseconds}ms ──');
}
/// 串行执行任务队列
///
/// 逐个 await不并发。每个任务独立 try-catch + 计时。
Future<void> _runTasksSequentially(List<InitTask> tasks) async {
for (var i = 0; i < tasks.length; i++) {
final task = tasks[i];
final sw = Stopwatch()..start();
try {
_log('[${i + 1}/${tasks.length}] ${task.name} ...');
await task.task();
sw.stop();
_log('[${i + 1}/${tasks.length}] ${task.name} done (${sw.elapsedMilliseconds}ms)');
} catch (e) {
sw.stop();
_log('[${i + 1}/${tasks.length}] ${task.name} FAILED (${sw.elapsedMilliseconds}ms): $e');
// 不 rethrow — 隔离失败,继续执行后续任务
}
}
}
void _log(String message) {
onLog?.call(message, tag: 'AppInit');
}
}
/// 初始化任务
class InitTask {
/// 任务名称(用于日志)
final String name;
/// 任务执行体
final Future<void> Function() task;
const InitTask({
required this.name,
required this.task,
});
}

View File

@@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:math';
/// 网络恢复退避防抖器
///
/// 网络状态频繁切换WiFi 不稳定、隧道信号间断等)时,
/// 避免每次恢复都立即重连,用指数退避控制重连频率。
///
/// 增加 jitter 防止多设备同时重连的群体效应thundering herd
///
/// ## 退避策略
///
/// - 首次触发 → 等 baseDelay 后执行
/// - 短时间内再次触发 → 延迟翻倍(指数退避)
/// - 长时间静默后触发 → 重置为 baseDelay网络已稳定
///
/// ## 退避进程(默认参数)
///
/// ```
/// 触发 1 → 4s 后执行
/// 触发 2 → 8s 后执行
/// 触发 3 → 16s 后执行
/// 触发 4 → 32s 后执行
/// 触发 5 → 60s封顶
/// ...静默超过 2 分钟...
/// 触发 N → 4s重置
/// ```
///
/// ## 使用
///
/// ```dart
/// final debouncer = NetworkBackoffDebouncer();
///
/// // 网络恢复事件
/// debouncer.call(() {
/// socketManager.reconnect();
/// });
/// ```
class NetworkBackoffDebouncer {
/// 初始延迟
final Duration baseDelay;
/// 退避上限
final Duration maxDelay;
/// 静默多久后重置为初始延迟(网络已稳定,不再退避)
final Duration resetThreshold;
/// 退避倍数
final double factor;
Duration _currentDelay;
DateTime? _lastTriggerTime;
Timer? _timer;
final _random = Random();
NetworkBackoffDebouncer({
this.baseDelay = const Duration(seconds: 4),
this.maxDelay = const Duration(seconds: 60),
this.resetThreshold = const Duration(minutes: 2),
this.factor = 2.0,
}) : _currentDelay = baseDelay;
/// 触发退避执行
///
/// 取消上一个待执行的 action按当前退避延迟重新计时。
/// 短时间内多次触发只执行最后一次,延迟逐步递增。
void call(void Function() action) {
final now = DateTime.now();
if (_lastTriggerTime == null ||
now.difference(_lastTriggerTime!) > resetThreshold) {
// 首次触发 or 长时间静默 → 重置
_currentDelay = baseDelay;
} else {
// 短时间内再次触发 → 退避
final nextMs = (_currentDelay.inMilliseconds * factor).toInt();
_currentDelay = Duration(
milliseconds: nextMs < maxDelay.inMilliseconds
? nextMs
: maxDelay.inMilliseconds,
);
}
_lastTriggerTime = now;
// 加 jitter+0~25%),防止多设备同时重连
final jitterMs = _random.nextInt((_currentDelay.inMilliseconds * 0.25).toInt().clamp(1, 15000));
final delayWithJitter = _currentDelay + Duration(milliseconds: jitterMs);
_timer?.cancel();
_timer = Timer(delayWithJitter, action);
}
/// 取消待执行的 action
void cancel() {
_timer?.cancel();
_timer = null;
}
/// 释放资源
void dispose() {
cancel();
}
}

View File

@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
/// 网络状态监听
///
/// 基于 connectivity_plus 监听平台网络变化,
/// 提供 [isConnected] 查询和 [onStatusChanged] 事件流。
///
/// 非单例,由 Riverpod Provider 构造注入。
///
/// ## 数据流位置
///
/// ```
/// connectivity_plus平台网络事件
/// → ★ NetworkMonitor ★ ← 你在这里
/// → SocketManager.handleNetworkStatusChanged()
/// → SocketClient.connect / disconnect
/// ```
///
/// ## 使用
///
/// ```dart
/// final monitor = ref.read(networkMonitorProvider);
/// monitor.onStatusChanged.listen((isAvailable) {
/// // 网络状态变化
/// });
/// ```
class NetworkMonitor {
final Connectivity _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _subscription;
List<ConnectivityResult> _results = [];
final _statusController = StreamController<bool>.broadcast();
/// 日志输出回调
final void Function(String message, {String? tag})? onLog;
NetworkMonitor({this.onLog});
/// 当前是否有网络连接
///
/// 排除无连接和仅蓝牙连接的情况。
bool get isConnected {
if (_results.isEmpty) return false;
return !_results.contains(ConnectivityResult.none) &&
!_results.every((r) => r == ConnectivityResult.bluetooth);
}
/// 网络状态变化流
///
/// 只在连接状态真正改变时发送事件connected ↔ disconnected
/// 同类型切换WiFi → 4G不会触发。
Stream<bool> get onStatusChanged => _statusController.stream;
/// 初始化监听
///
/// App 启动时调用一次。获取当前状态并开始监听变化。
Future<void> initialize() async {
try {
_results = await _connectivity.checkConnectivity();
_log('Network status: $_connectionDescription');
} catch (e) {
_log('Failed to check connectivity: $e');
_results = [ConnectivityResult.none];
}
_subscription = _connectivity.onConnectivityChanged.listen(
_onChanged,
onError: (Object error) {
_log('Connectivity listener error: $error');
},
);
}
/// 释放资源
void dispose() {
_subscription?.cancel();
_statusController.close();
}
// ── 内部 ──────────────────────────────────────────────────────────────────
void _onChanged(List<ConnectivityResult> results) {
final wasConnected = isConnected;
_results = results;
final nowConnected = isConnected;
_log('Network changed: $_connectionDescription');
// 只在真正切换时通知
if (wasConnected != nowConnected) {
_statusController.add(nowConnected);
_log(nowConnected ? 'Network restored' : 'Network lost');
}
}
String get _connectionDescription {
if (_results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (_results.contains(ConnectivityResult.mobile)) return 'Mobile';
if (_results.contains(ConnectivityResult.ethernet)) return 'Ethernet';
if (_results.contains(ConnectivityResult.none)) return 'None';
return 'Unknown';
}
void _log(String message) {
onLog?.call(message, tag: 'Network');
}
}

View File

@@ -0,0 +1,376 @@
import 'dart:async';
import 'package:networks_sdk/networks_sdk.dart';
import 'network_backoff_debouncer.dart';
/// 消息预处理回调
///
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
/// App 层在 Provider 装配时注入解密/解析逻辑,
/// 不在 SDK 内部调用加解密 SDK。
typedef MessageTransformer = Map<String, dynamic> Function(
Map<String, dynamic> raw,
);
/// WebSocket 连接管理
///
/// 在 SocketClientSDK 底层能力)之上封装:
/// - 连接/断连生命周期(登录连接、登出断连)
/// - 前后台生命周期(后台断连省电、前台自动重连)
/// - 网络状态响应(断网断连、恢复网络立即重连)
/// - 操作前置检查(网络可用性 + 后台状态)
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
/// - 发送 API 透传
///
/// 不使用单例,通过 Riverpod Provider 注入。
///
/// ## 数据流位置
///
/// ```
/// SocketClient.messageStream原始消息
/// → onMessageTransform?解密回调App 层注入)
/// → ★ SocketManager.messageStream ★ ← 你在这里
/// → 业务模块消费
/// ```
///
/// ## 生命周期流程
///
/// ```
/// 登录成功 → connect(token) → 前置检查 → 建立连接
/// App 进后台 → onEnterBackground() → 断开连接(省电)
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
/// 网络丢失 → handleNetworkLost() → 断开连接
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
/// 登出 → disconnect() → 断开连接,清除 token
/// ```
///
/// ## 前置检查策略
///
/// 所有会发起网络操作的方法都先检查前置条件:
/// - connect → 检查网络可用性 + 是否在后台
/// - send / sendString → 检查连接状态 + 是否在后台
/// - onEnterForeground 重连 → 检查网络可用性
class SocketManager {
final NetworksMessagingApi _client;
final String _wsUrl;
/// 消息预处理回调
///
/// 登录后由 Provider 层注入,用于消息解密等。
/// 不注入时直接透传原始消息。
///
/// TODO: 接入加解密 SDK 后实现
final MessageTransformer? onMessageTransform;
/// 网络可用性查询App 层注入)
///
/// 与 HTTP 层 [ApiConfig.onCheckNetworkAvailable] 对称。
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
final Future<bool> Function()? onCheckNetworkAvailable;
/// 日志回调
final void Function(String message, {String? tag})? onLog;
// ── 内部状态 ──
/// 上次连接使用的 token用于前台/网络恢复时自动重连
String? _lastToken;
/// 后台断连标记:前台恢复时需要重连
bool _reconnectOnForeground = false;
/// 断网标记:网络恢复时需要重连
bool _reconnectOnNetworkRestore = false;
/// 当前是否在后台
bool _isInBackground = false;
/// 网络恢复退避防抖器
///
/// 网络抖动(快速 offline → online 切换)时,
/// 用指数退避避免反复锤服务器。
/// 通过 [NetworkBackoffDebouncer] 实现指数退避。
final NetworkBackoffDebouncer _networkDebouncer = NetworkBackoffDebouncer();
/// 前台恢复延迟重连定时器
///
/// 回前台后延迟 500ms 等待网络稳定再重连。
/// 期间如果再次进后台 / 主动断连 / dispose及时取消。
Timer? _foregroundReconnectTimer;
SocketManager({
required NetworksMessagingApi client,
required String wsUrl,
this.onMessageTransform,
this.onCheckNetworkAvailable,
this.onLog,
}) : _client = client,
_wsUrl = wsUrl;
// ── 连接 ──────────────────────────────────────────────────────────────────
/// 连接 WebSocket
///
/// 登录成功后调用token 从登录响应获取。
/// URL 由 Provider 层从 AppConfig 构建后注入,此处不关心来源。
///
/// 前置检查:
/// - 在后台 → 跳过,标记前台恢复时重连
/// - 无网络 → 跳过,标记网络恢复时重连
Future<bool> connect({required String token}) async {
_lastToken = token;
_reconnectOnForeground = false;
_reconnectOnNetworkRestore = false;
// 前置检查:在后台不连接(省电)
if (_isInBackground) {
_reconnectOnForeground = true;
_log('In background, defer connect to foreground');
return false;
}
// 前置检查:无网络不连接
if (!await _isNetworkAvailable()) {
_reconnectOnNetworkRestore = true;
_log('No network, defer connect to network restore');
return false;
}
_log('Connecting...');
return _client.connect(_wsUrl, token: token);
}
/// 断开连接(主动断连)
///
/// 登出时调用。清除 token取消所有待执行的重连不再自动重连。
Future<void> disconnect() async {
_lastToken = null;
_reconnectOnForeground = false;
_reconnectOnNetworkRestore = false;
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
_networkDebouncer.cancel();
_log('Disconnecting (manual)');
await _client.disconnect();
}
/// 当前是否已连接
bool get isConnected => _client.isConnected;
/// 当前连接状态
SocketConnectionState get connectionState => _client.connectionState;
/// 当前是否在后台
bool get isInBackground => _isInBackground;
// ── 前后台生命周期 ────────────────────────────────────────────────────────
//
// 后台 → 断连(省电省流量)
// 前台 → 自动重连(如果之前有连接)
/// App 进后台 → 断开连接,标记前台恢复时重连
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
void onEnterBackground() {
_isInBackground = true;
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
_client.onEnterBackground();
if (_lastToken == null) return; // 未登录,无需处理
// 与 _handleNetworkLost 保持一致:
// 不仅 connectedconnecting / reconnecting 也要断开,
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
if (_client.isConnected ||
_client.connectionState == SocketConnectionState.connecting ||
_client.connectionState == SocketConnectionState.reconnecting) {
_reconnectOnForeground = true;
_log('Entering background, disconnecting to save battery');
_client.disconnect();
}
}
/// App 回前台 → 自动重连(如果之前后台断连)
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
void onEnterForeground() {
_isInBackground = false;
_client.onEnterForeground();
if (_reconnectOnForeground && _lastToken != null) {
_reconnectOnForeground = false;
_log('Returning to foreground, reconnecting...');
// 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = Timer(
const Duration(milliseconds: 500),
() async {
_foregroundReconnectTimer = null;
// 双重保险:回调执行时再次检查后台状态
if (_isInBackground) {
_reconnectOnForeground = true;
_log('Went back to background during delay, skip reconnect');
return;
}
if (!_client.isConnected && _lastToken != null) {
// 前置检查:网络可用性
if (!await _isNetworkAvailable()) {
_reconnectOnNetworkRestore = true;
_log('Network unavailable, defer reconnect to network restore');
return;
}
_client.connect(_wsUrl, token: _lastToken!);
}
},
);
}
}
// ── 网络状态变化 ──────────────────────────────────────────────────────────
//
// 网络丢失 → 断连(避免无效重试消耗资源)
// 网络恢复 → 退避重连(防网络抖动)
/// 网络状态变化处理
///
/// 由 App 层 NetworkMonitor.onStatusChanged 事件驱动。
void handleNetworkStatusChanged({required bool isAvailable}) {
if (isAvailable) {
_handleNetworkRestored();
} else {
_handleNetworkLost();
}
}
/// 网络丢失 → 断开连接,标记网络恢复时重连
///
/// 断网后继续重试没有意义,主动断连避免无效重连消耗资源。
void _handleNetworkLost() {
if (_lastToken == null) return; // 未登录,无需处理
if (_client.isConnected ||
_client.connectionState == SocketConnectionState.connecting ||
_client.connectionState == SocketConnectionState.reconnecting) {
_reconnectOnNetworkRestore = true;
_log('Network lost, disconnecting');
_client.disconnect();
}
}
/// 网络恢复 → 退避重连
///
/// 通过 [NetworkBackoffDebouncer] 控制重连频率,
/// 网络抖动(快速 offline/online 切换)时不会反复锤服务器。
///
/// 退避进程4s → 8s → 16s → 32s → 60s封顶
/// 网络稳定超过 2 分钟后重置。
void _handleNetworkRestored() {
if (_reconnectOnNetworkRestore && _lastToken != null) {
_reconnectOnNetworkRestore = false;
// 在后台不重连,等前台恢复时再连
if (_isInBackground) {
_reconnectOnForeground = true;
_log('Network restored but in background, defer to foreground');
return;
}
_log('Network restored, scheduling reconnect with backoff');
_networkDebouncer.call(() {
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
_log('Backoff timer fired, reconnecting');
_client.connect(_wsUrl, token: _lastToken!);
}
});
}
}
// ── 消息流 ────────────────────────────────────────────────────────────────
/// 处理后的 JSON 消息流
///
/// 经过 [onMessageTransform] 预处理(解密等)后的消息。
/// 业务模块应监听此流,不直接监听 SocketClient.messageStream。
Stream<Map<String, dynamic>> get messageStream {
if (onMessageTransform != null) {
return _client.messageStream.map(onMessageTransform!);
}
return _client.messageStream;
}
/// 原始消息流(不经预处理,调试用)
Stream<String> get rawMessageStream => _client.rawMessageStream;
/// 连接状态变化流
Stream<SocketConnectionState> get connectionStateStream =>
_client.connectionStateStream;
/// 错误流
Stream<SocketError> get errorStream => _client.errorStream;
// ── 发送 ──────────────────────────────────────────────────────────────────
/// 发送 JSON 消息
///
/// 前置检查:未连接或在后台时不发送。
Future<bool> send(Map<String, dynamic> message) {
if (!_canSend()) return Future.value(false);
return _client.send(message);
}
/// 发送原始字符串
///
/// 前置检查:未连接或在后台时不发送。
Future<bool> sendString(String message) {
if (!_canSend()) return Future.value(false);
return _client.sendString(message);
}
// ── 释放 ──────────────────────────────────────────────────────────────────
/// 释放所有资源
Future<void> dispose() {
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
_networkDebouncer.dispose();
return _client.dispose();
}
// ── 内部 ──────────────────────────────────────────────────────────────────
/// 发送前置检查
///
/// 两重保险:连接状态 + 后台状态。
/// 后台已断连所以 isConnected 通常就能拦住,
/// 但显式检查 _isInBackground 防止边界情况遗漏。
bool _canSend() {
if (!_client.isConnected) {
_log('Not connected, cannot send');
return false;
}
if (_isInBackground) {
_log('In background, skip send');
return false;
}
return true;
}
/// 查询网络可用性
///
/// 未注入回调时默认网络可用(不阻塞操作)。
Future<bool> _isNetworkAvailable() async {
if (onCheckNetworkAvailable == null) return true;
return onCheckNetworkAvailable!();
}
void _log(String message) {
onLog?.call(message, tag: 'SocketManager');
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'colors.dart';
import 'font.dart';
/// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData
///
/// 同时提供 Light / Dark 双主题,按钮形状/颜色/字体统一在此定义,
/// AppButton 只负责变体切换和 loading 逻辑,不硬编码颜色和字体。
///
/// ## 数据流位置
///
/// ```
/// AppColors + AppFont (L1 常量)
/// → ★ AppTheme ★ (L1 组装) ← 你在这里
/// → MaterialApp(theme: AppTheme.theme, darkTheme: AppTheme.darkTheme)
/// → Theme.of(context) → 所有 Widget 自动响应主题变化
/// ```
///
/// ## 使用
///
/// ```dart
/// // app/app.dart
/// MaterialApp(
/// theme: AppTheme.theme, // getter 名与 MaterialApp 参数名一一对应
/// darkTheme: AppTheme.darkTheme,
/// )
/// ```
class AppTheme {
AppTheme._();
/// 亮色主题 — 对应 MaterialApp `theme:` 参数
static ThemeData get theme => _build(Brightness.light);
/// 暗色主题 — 对应 MaterialApp `darkTheme:` 参数
static ThemeData get darkTheme => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) {
final isDark = brightness == Brightness.dark;
final primary = isDark ? AppColors.primaryLight : AppColors.primary;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: ColorScheme(
brightness: brightness,
primary: primary,
onPrimary: AppColors.white,
secondary: primary,
onSecondary: AppColors.white,
error: AppColors.error,
onError: AppColors.white,
surface: isDark ? AppColors.gray800 : AppColors.white,
onSurface: isDark ? AppColors.white : AppColors.gray900,
),
scaffoldBackgroundColor: isDark ? AppColors.gray900 : AppColors.gray50,
// 字体
textTheme: AppFont.textTheme(brightness),
// ElevatedButton → AppButton.primary
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.gray400,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// OutlinedButton → AppButton.secondary
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primary,
side: BorderSide(color: primary),
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// TextButton → AppButton.text
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primary,
minimumSize: const Size.fromHeight(48),
),
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// 颜色体系 — 与 Figma 设计稿对应
///
/// L1 基础常量 -- 不含任何 Widget只输出颜色常量。
/// View 层不直接引用 AppColors通过 Theme.of(context) 访问语义色;
/// 有特殊硬编码需求(插图、固定品牌色)时可直接引用。
///
/// ## 数据流位置
///
/// ```
/// AppColors颜色常量← 你在这里
/// → AppTheme组装为 ThemeData
/// → MaterialApp注入
/// → Theme.of(context)View 层消费)
/// ```
class AppColors {
AppColors._();
// ── Brand Primary ──────────────────────────────────────────────────────────
static const primary = Color(0xFF2F80ED);
static const primaryDark = Color(0xFF1A6BD4);
static const primaryLight = Color(0xFF5BA3F5);
// ── Semantic ───────────────────────────────────────────────────────────────
static const success = Color(0xFF27AE60);
static const warning = Color(0xFFF2C94C);
static const error = Color(0xFFEB5757);
// ── Neutral Gray Scale ─────────────────────────────────────────────────────
static const white = Color(0xFFFFFFFF);
static const gray50 = Color(0xFFF8F9FA);
static const gray100 = Color(0xFFF1F3F4);
static const gray200 = Color(0xFFE8EAED);
static const gray400 = Color(0xFFBDC1C6);
static const gray600 = Color(0xFF80868B);
static const gray800 = Color(0xFF3C4043);
static const gray900 = Color(0xFF202124);
static const black = Color(0xFF000000);
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'font.dart';
/// 主题样式快捷封装
///
/// `context.styles` 返回此对象build 方法里一行获取所有样式,
/// 之后直接用 `s.bodySmall`、`s.primary`,不再写 Theme.of(context)。
///
/// ```dart
/// final s = context.styles;
///
/// Text('标题', style: s.titleMedium)
/// Text('描述', style: s.bodySmall)
/// Icon(Icons.home, color: s.primary)
/// Text('改色', style: s.bodySmall?.copyWith(color: s.primary))
/// ```
class AppStyles {
AppStyles(BuildContext context)
: _t = Theme.of(context).textTheme,
_c = Theme.of(context).colorScheme;
final TextTheme _t;
final ColorScheme _c;
// ── 字体 ──────────────────────────────────────────────────────────────────
TextStyle? get displayLarge => _t.displayLarge;
TextStyle? get displayMedium => _t.displayMedium;
TextStyle? get displaySmall => _t.displaySmall;
TextStyle? get headlineLarge => _t.headlineLarge;
TextStyle? get headlineMedium => _t.headlineMedium;
TextStyle? get headlineSmall => _t.headlineSmall;
TextStyle? get titleLarge => _t.titleLarge;
TextStyle? get titleMedium => _t.titleMedium;
TextStyle? get titleSmall => _t.titleSmall;
TextStyle? get bodyLarge => _t.bodyLarge;
TextStyle? get bodyMedium => _t.bodyMedium;
TextStyle? get bodySmall => _t.bodySmall;
TextStyle? get labelLarge => _t.labelLarge;
TextStyle? get labelMedium => _t.labelMedium;
TextStyle? get labelSmall => _t.labelSmall;
// ── 颜色 + 亮暗 ───────────────────────────────────────────────────────────
Brightness get brightness => _c.brightness;
bool get isDark => _c.brightness == Brightness.dark;
Color get primary => _c.primary;
Color get onPrimary => _c.onPrimary;
Color get secondary => _c.secondary;
Color get onSecondary => _c.onSecondary;
Color get error => _c.error;
Color get onError => _c.onError;
Color get surface => _c.surface;
Color get onSurface => _c.onSurface;
Color get outline => _c.outline;
Color get outlineVariant => _c.outlineVariant;
// ── 预组合样式(字体 + 颜色,开箱即用)──────────────────────────────────────
//
// 与 AppButton 变体理念一致:按语义选用,无需手动拼 TextStyle 或 copyWith。
// 新增场景时在此扩展,保持全局一致。
/// 分组标题 — 列表 Section、设置分组等sectionLabel 字体 + primary 色)
TextStyle get sectionLabel => AppFont.sectionLabel.copyWith(color: primary);
/// 辅助文字 — 元数据、次要信息、时间戳等labelMedium + outline 色)
TextStyle? get labelMuted => labelMedium?.copyWith(color: outline);
/// 正文次要 — 描述、提示等bodySmall + outline 色)
TextStyle? get bodyMuted => bodySmall?.copyWith(color: outline);
/// 错误提示 — 表单错误、警告等bodySmall + error 色)
TextStyle? get bodyError => bodySmall?.copyWith(color: error);
}
/// BuildContext 主题入口
///
/// ```dart
/// final s = context.styles;
/// ```
extension AppThemeX on BuildContext {
AppStyles get styles => AppStyles(this);
}

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
/// 字体体系 -- 与 Figma 设计稿对应
///
/// L1 基础常量 — 不含颜色,只定义字号/字重/行高/字距。
/// View 层通过 [AppStyles]`context.styles`)消费,颜色由主题决定。
/// 特殊场景(固定样式、不跟主题)可直接引用 AppFont。
///
/// ## 数据流位置
///
/// ```
/// AppFont字体常量← 你在这里
/// → AppTheme组装为 TextTheme → ThemeData
/// → MaterialApp注入
/// → context.stylesView 层消费)
/// ```
///
/// ## 使用
///
/// ```dart
/// // 推荐:通过 context.styles 消费(自动响应亮暗主题)
/// final s = context.styles;
/// Text('标题', style: s.headlineMedium);
/// Text('分组', style: s.sectionLabel); // 预组合:字体 + 主题色
///
/// // 特殊场景:固定样式,不跟主题切换
/// Text('固定', style: AppFont.bodyMedium);
/// ```
class AppFont {
AppFont._();
// ── 字体族 ──────────────────────────────────────────────────────────────
/// 默认字体族(系统字体)
///
/// 接入自定义字体时只需修改此常量 + pubspec.yaml fonts 配置。
static const String? _fontFamily = null; // null = 系统默认字体
// ── Display -- 超大展示(启动页、空状态大标题)──────────────────────────
static const displayLarge = TextStyle(
fontFamily: _fontFamily,
fontSize: 57,
fontWeight: FontWeight.w400,
letterSpacing: -0.25,
height: 64 / 57,
);
static const displayMedium = TextStyle(
fontFamily: _fontFamily,
fontSize: 45,
fontWeight: FontWeight.w400,
height: 52 / 45,
);
static const displaySmall = TextStyle(
fontFamily: _fontFamily,
fontSize: 36,
fontWeight: FontWeight.w400,
height: 44 / 36,
);
// ── Headline -- 页面标题导航栏、Section 标题)────────────────────────
static const headlineLarge = TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w400,
height: 40 / 32,
);
static const headlineMedium = TextStyle(
fontFamily: _fontFamily,
fontSize: 28,
fontWeight: FontWeight.w400,
height: 36 / 28,
);
static const headlineSmall = TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w400,
height: 32 / 24,
);
// ── Title -- 卡片 / 列表标题(聊天列表名称、设置项标题)──────────────
static const titleLarge = TextStyle(
fontFamily: _fontFamily,
fontSize: 22,
fontWeight: FontWeight.w500,
height: 28 / 22,
);
static const titleMedium = TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
letterSpacing: 0.15,
height: 24 / 16,
);
static const titleSmall = TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 20 / 14,
);
// ── Body -- 正文内容(聊天气泡、表单输入、描述文字)──────────────────
static const bodyLarge = TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 24 / 16,
);
static const bodyMedium = TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.25,
height: 20 / 14,
);
static const bodySmall = TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.4,
height: 16 / 12,
);
// ── Label -- 按钮 / 标签 / 辅助文字按钮文字、Tab、Badge──────────
static const labelLarge = TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 20 / 14,
);
static const labelMedium = TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 16 / 12,
);
static const labelSmall = TextStyle(
fontFamily: _fontFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 16 / 11,
);
// ── 语义字体(超出 M3 标准级别的产品专属规格)────────────────────────────
//
// 这里只定义字号/字重/字距,不含颜色。
// 颜色由 AppStyles 的预组合样式注入(如 AppStyles.sectionLabel
/// 分组标题:列表 Section、设置分组等13 / w600 / 0.5 字距)
static const sectionLabel = TextStyle(
fontFamily: _fontFamily,
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
);
// ── 组装 TextTheme供 AppTheme 调用)──────────────────────────────────
/// 根据亮暗模式组装 TextTheme
///
/// 默认亮暗共用同一套字体规格。需要按模式区分时,
/// 用 copyWith 覆盖个别样式即可,不影响其他级别。
///
/// 示例 -- 暗色模式下 labelLarge 改为 regular
/// ```dart
/// labelLarge: isDark
/// ? labelLarge.copyWith(fontWeight: FontWeight.w400)
/// : labelLarge,
/// ```
///
/// AppTheme._build() 中调用:
/// ```dart
/// textTheme: AppFont.textTheme(brightness),
/// ```
static TextTheme textTheme(Brightness brightness) {
// final isDark = brightness == Brightness.dark;
return TextTheme(
displayLarge: displayLarge,
displayMedium: displayMedium,
displaySmall: displaySmall,
headlineLarge: headlineLarge,
headlineMedium: headlineMedium,
headlineSmall: headlineSmall,
titleLarge: titleLarge,
titleMedium: titleMedium,
titleSmall: titleSmall,
bodyLarge: bodyLarge,
bodyMedium: bodyMedium,
bodySmall: bodySmall,
labelLarge: labelLarge,
labelMedium: labelMedium,
labelSmall: labelSmall,
);
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../base/context_theme_ext.dart';
/// # AppButton — 按钮原子组件L2 Component
///
/// 四种命名构造器对应四种变体loading 状态自动禁用点击。
/// 颜色和形状由 AppTheme 定义AppButton 只做变体切换和 loading 逻辑。
///
/// ## 数据流位置
///
/// ```
/// View 层 Widget 树
/// → ★ AppButton.primary / .secondary / .text / .inverse ★ ← 你在这里
/// → ElevatedButton / OutlinedButton / TextButton / FilledButton
/// → AppTheme颜色 / 形状已在 ThemeData 中定义)
/// ```
///
/// ## 使用
///
/// ```dart
/// // 主按钮(全宽,填充色)
/// AppButton.primary(label: '登录', onPressed: () => vm.login()),
///
/// // 加载状态(禁用点击,显示进度圈)
/// AppButton.primary(label: '登录', onPressed: null, isLoading: true),
///
/// // 副按钮(描边)
/// AppButton.secondary(label: '注册', onPressed: () {}),
///
/// // 文字按钮(非全宽)
/// AppButton.text(label: '忘记密码?', onPressed: () {}),
///
/// // 反色按钮:亮色模式黑底白字,暗色模式白底黑字
/// AppButton.inverse(
/// label: '切换 Tab',
/// icon: const Icon(Icons.swap_horiz),
/// onPressed: () {},
/// ),
/// ```
enum _ButtonVariant { primary, secondary, text, inverse }
class AppButton extends StatelessWidget {
const AppButton.primary({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
this.fullWidth = true,
}) : _variant = _ButtonVariant.primary,
icon = null;
const AppButton.secondary({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
this.fullWidth = true,
}) : _variant = _ButtonVariant.secondary,
icon = null;
const AppButton.text({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
this.fullWidth = false,
}) : _variant = _ButtonVariant.text,
icon = null;
/// 反色按钮:颜色随明暗主题取反。
///
/// 亮色模式:黑色背景 + 白色文字。
/// 暗色模式:白色背景 + 黑色文字。
///
/// 可选传 [icon]`Icon` widget自动切换为带图标布局。
const AppButton.inverse({
super.key,
required this.label,
this.onPressed,
this.isLoading = false,
this.fullWidth = false,
this.icon,
}) : _variant = _ButtonVariant.inverse;
final String label;
final VoidCallback? onPressed;
final bool isLoading;
final bool fullWidth;
/// 仅 [AppButton.inverse] 使用,其余变体固定为 null
final Widget? icon;
final _ButtonVariant _variant;
@override
Widget build(BuildContext context) {
final label = isLoading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: Text(this.label);
final button = switch (_variant) {
_ButtonVariant.primary =>
ElevatedButton(onPressed: isLoading ? null : onPressed, child: label),
_ButtonVariant.secondary =>
OutlinedButton(onPressed: isLoading ? null : onPressed, child: label),
_ButtonVariant.text =>
TextButton(onPressed: isLoading ? null : onPressed, child: label),
_ButtonVariant.inverse => _buildInverse(context, label),
};
return fullWidth ? SizedBox(width: double.infinity, child: button) : button;
}
Widget _buildInverse(BuildContext context, Widget label) {
final s = context.styles;
final isDark = s.isDark;
final bg = isDark ? Colors.white : Colors.black;
final fg = isDark ? Colors.black : Colors.white;
final style = FilledButton.styleFrom(
backgroundColor: bg,
foregroundColor: fg,
);
if (icon != null) {
return FilledButton.icon(
onPressed: isLoading ? null : onPressed,
style: style,
icon: icon!,
label: label,
);
}
return FilledButton(
onPressed: isLoading ? null : onPressed,
style: style,
child: label,
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../components/app_button.dart';
/// # AppDialog — 业务确认弹窗L3 Composite
///
/// 封装 showDialog统一弹窗交互规范标题 + 内容 + 确认/取消)。
/// 内部使用 AppButton展示 L3 → L2 → L1 的完整组合链路。
///
/// ## 数据流位置
///
/// ```
/// View 层调用
/// → AppDialog.show() ← 你在这里(静态入口)
/// → showDialog<bool>
/// → AppDialog widgetAlertDialog 布局)
/// → AppButton.text取消
/// → AppButton.primary确认
/// ← Future<bool?> → true=确认, false=取消, null=点背景关闭
/// ```
///
/// ## 使用
///
/// ```dart
/// // View 层
/// final confirmed = await AppDialog.show(
/// context,
/// title: '删除联系人',
/// content: '确定要删除该联系人吗?此操作不可恢复。',
/// confirmLabel: '删除',
/// );
/// if (confirmed == true) {
/// ref.read(contactViewModelProvider.notifier).deleteContact(id);
/// }
/// ```
class AppDialog extends StatelessWidget {
const AppDialog._({
required this.title,
required this.content,
required this.confirmLabel,
this.cancelLabel,
});
final String title;
final String content;
final String confirmLabel;
final String? cancelLabel;
/// 显示确认弹窗
///
/// 返回:`true` = 确认,`false` = 取消,`null` = 点背景关闭
static Future<bool?> show(
BuildContext context, {
required String title,
required String content,
String confirmLabel = '确定', // TODO: 接入国际化
String? cancelLabel = '取消', // TODO: 接入国际化
bool barrierDismissible = true,
}) =>
showDialog<bool>(
context: context,
barrierDismissible: barrierDismissible,
builder: (_) => AppDialog._(
title: title,
content: content,
confirmLabel: confirmLabel,
cancelLabel: cancelLabel,
),
);
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
if (cancelLabel != null)
AppButton.text(
label: cancelLabel!,
fullWidth: false,
onPressed: () => Navigator.of(context).pop(false),
),
AppButton.primary(
label: confirmLabel,
fullWidth: false,
onPressed: () => Navigator.of(context).pop(true),
),
],
);
}

0
apps/im_app/lib/data/cache/.gitkeep vendored Normal file
View File

View File

@@ -0,0 +1,40 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/tables/users.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Users])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (m) async {
await m.createAll();
},
onUpgrade: (m, from, to) async {
// 自动检测并添加缺失列
for (final table in allTables) {
//取原来的字段
final existingColumns = await m.database
.customSelect('PRAGMA table_info(${table.actualTableName})')
.get();
final existingNames = existingColumns
.map((r) => r.data['name'] as String)
.toSet();
for (final column in table.$columns) {
if (!existingNames.contains(column.name)) {
//字段缺失,添加。
await m.addColumn(table, column);
}
}
}
},
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:drift/drift.dart';
@DataClassName('User')
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get uid => integer().nullable()();
TextColumn get uuid => text().nullable()();
IntColumn get lastOnline => integer().nullable()();
TextColumn get profilePic => text().nullable()();
TextColumn get profilePicGaussian => text().withDefault(const Constant(''))();
TextColumn get nickname => text().nullable()();
TextColumn get depositName => text().nullable()();
IntColumn get hasSetDepositName => integer().withDefault(const Constant(0))();
TextColumn get contact => text().nullable()();
TextColumn get countryCode => text().nullable()();
TextColumn get username => text().nullable()();
IntColumn get role => integer().nullable()();
IntColumn get relationship => integer().nullable()();
IntColumn get friendStatus => integer().nullable()();
TextColumn get bio => text().nullable()();
TextColumn get userAlias => text().nullable()();
IntColumn get requestAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
TextColumn get email => text().nullable()();
TextColumn get recoveryEmail => text().nullable()();
TextColumn get remark => text().nullable()();
TextColumn get source => text().nullable()();
IntColumn get addIndex => integer().nullable()();
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
IntColumn get notificationSoundId => integer().withDefault(const Constant(0))();
IntColumn get sendMessageSoundId => integer().withDefault(const Constant(0))();
IntColumn get groupNotificationSoundId => integer().withDefault(const Constant(0))();
TextColumn get groupTags => text().withDefault(const Constant('[]'))();
TextColumn get friendTags => text().withDefault(const Constant('[]'))();
TextColumn get publicKey => text().nullable()();
IntColumn get configBits => integer().withDefault(const Constant(0))();
TextColumn get hint => text().nullable()();
@override
String get tableName => 'user';
}

View File

@@ -0,0 +1,68 @@
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/user.dart';
part 'user_dto.g.dart';
/// 用户 DTOData Transfer Object
///
/// local / remote 共用的数据传输对象,放在 data/models/。
/// 提供与 Domain Entity [User] 之间的双向转换。
///
/// ## 数据流位置(本地存储场景)
///
/// ```
/// 写入本地:
/// LoginData.toEntity() → User
/// → UserDto.fromEntity(user) → ★ UserDto ★ ← 你在这里
/// → toJson() → SQLite / SharedPreferences
///
/// 读取本地:
/// SQLite / SharedPreferences → JSON
/// → ★ UserDto.fromJson() ★ ← 你在这里
/// → UserDto.toEntity() → User
/// → ViewModel.state → View
/// ```
///
/// 注意:登录接口的 Response DTO 是 [LoginData](含 token
/// 本类用于纯用户信息的本地持久化,不含 token。
@JsonSerializable()
class UserDto {
@JsonKey(name: 'user_id')
final String userId;
final String email;
final String? nickname;
final String? avatar;
const UserDto({
required this.userId,
required this.email,
this.nickname,
this.avatar,
});
factory UserDto.fromJson(Map<String, dynamic> json) =>
_$UserDtoFromJson(json);
Map<String, dynamic> toJson() => _$UserDtoToJson(this);
/// DTO → Domain Entity
User toEntity() {
return User(
id: userId,
email: email,
nickname: nickname,
avatar: avatar,
);
}
/// Domain Entity → DTO
factory UserDto.fromEntity(User user) {
return UserDto(
userId: user.id,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart';
import '../../../domain/entities/user.dart';
part 'get_profile_request.g.dart';
/// # /user/profile — 获取用户资料GET 请求示例)
///
/// 演示GET 请求 + 无 body 参数的模式。
/// GET 请求的 toJson() 结果会自动作为 URL query parameters 发送。
///
/// ## 数据流位置
///
/// ```
/// UserRepositoryImpl.getProfile()
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
/// → 服务端 GET /user/profile
/// → 响应 JSON → ★ ProfileData ★ ← 也在这里
/// → ProfileData.toEntity() → User
/// ```
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
/// 用户资料响应 DTO只需反序列化禁止生成无用的 toJson
@JsonSerializable(createToJson: false)
class ProfileData {
@JsonKey(name: 'user_id')
final String userId;
final String email;
final String? nickname;
final String? avatar;
const ProfileData({
required this.userId,
required this.email,
this.nickname,
this.avatar,
});
factory ProfileData.fromJson(Map<String, dynamic> json) =>
_$ProfileDataFromJson(json);
/// DTO → Domain Entity
User toEntity() {
return User(
id: userId,
email: email,
nickname: nickname,
avatar: avatar,
);
}
}
// ─────────────────────────────────────────────
// Request
// ─────────────────────────────────────────────
/// 获取用户资料请求GET无参数
///
/// GET 请求无 bodytoJson() 返回空 map。
/// 如需 query 参数(如分页),添加字段即可,
/// toJson() 会自动将字段序列化为 URL query string。
@ApiRequest(
path: ApiPaths.userProfile,
method: HttpMethod.get,
responseType: ProfileData,
)
@JsonSerializable()
class GetProfileRequest extends ApiRequestable<ProfileData>
with _$GetProfileRequestApi {
GetProfileRequest();
factory GetProfileRequest.fromJson(Map<String, dynamic> json) =>
_$GetProfileRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
}

View File

@@ -0,0 +1,90 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart';
import '../../../domain/entities/user.dart';
part 'login_request.g.dart';
/// # /auth/login — 登录接口
///
/// 一个端点 = 一个文件Response DTO + Request 放在同一文件中。
///
/// ## 数据流位置
///
/// ```
/// AuthRepositoryImpl.login(email, password)
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → 服务端 POST /auth/login
/// → 响应 JSON → ★ LoginData ★ ← 也在这里
/// → LoginData.toEntity() → User
/// ```
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
/// 登录响应 DTO
///
/// 服务端返回的登录数据,包含 token 和用户信息。
/// 通过 [toEntity] 转换为 Domain Entity [User]。
@JsonSerializable()
class LoginData {
final String token;
@JsonKey(name: 'user_id')
final String userId;
final String email;
final String? nickname;
final String? avatar;
const LoginData({
required this.token,
required this.userId,
required this.email,
this.nickname,
this.avatar,
});
factory LoginData.fromJson(Map<String, dynamic> json) =>
_$LoginDataFromJson(json);
Map<String, dynamic> toJson() => _$LoginDataToJson(this);
/// DTO → Domain Entity
User toEntity() {
return User(
id: userId,
email: email,
nickname: nickname,
avatar: avatar,
);
}
}
// ─────────────────────────────────────────────
// Request
// ─────────────────────────────────────────────
/// 登录请求
///
/// `@ApiRequest` 自动生成 `_$LoginRequestApi` mixin
/// 提供 path / method / requestType / includeToken / fromJson 自动注册。
@ApiRequest(
path: ApiPaths.authLogin,
method: HttpMethod.post,
responseType: LoginData,
requestType: ApiRequestType.login,
)
@JsonSerializable()
class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi {
final String email;
final String password;
LoginRequest({required this.email, required this.password});
factory LoginRequest.fromJson(Map<String, dynamic> json) =>
_$LoginRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}

View File

@@ -0,0 +1,32 @@
import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart';
/// # /auth/logout — 登出接口(无响应数据示例)
///
/// 演示POST 请求 + 无 Response DTO 的模式。
/// 服务端返回 `{"code": 0, "message": "ok"}` 无 data 字段,
/// `executeRequest` 返回 null调用方直接 await 即可。
///
/// 此接口不使用 @ApiRequest 注解,直接实现 ApiRequestable
/// 演示手动实现方式(适用于不需要代码生成器的简单接口)。
///
/// ## 数据流位置
///
/// ```
/// AuthRepositoryImpl.logout()
/// → _client.executeRequest( ★ LogoutRequest ★ ) ← 你在这里
/// → 服务端 POST /auth/logout
/// → 响应 {"code": 0, "message": "ok"} → null
/// ```
class LogoutRequest extends ApiRequestable<void> {
@override
String get path => ApiPaths.authLogout;
@override
HttpMethod get method => HttpMethod.post;
/// 登出不需要请求体参数
@override
Map<String, dynamic> toJson() => {};
}

View File

@@ -0,0 +1,160 @@
import 'dart:typed_data';
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart';
part 'upload_file_request.g.dart';
/// # /upload/file — 文件上传Upload 请求示例)
///
/// 演示两种上传模式:
///
/// ## 模式 A: FormData 上传到自有后端
/// 适用于后端直接接收文件的场景。
/// 使用 [UploadFileRequest] — path 为相对路径SDK 自动拼 baseURL。
///
/// ## 模式 B: 二进制上传到 S3 presigned URL
/// 适用于先向后端获取 presigned URL再直接上传到 S3 的场景。
/// 使用 [S3UploadRequest] — path 为完整 URLoverride decodeResponse。
///
/// ## Upload 与普通请求的区别
///
/// | 普通请求 | Upload 请求 |
/// |---------|-----------|
/// | `toJson()` → JSON body | `uploadData` → FormData / Uint8List |
/// | `requestType: request` | `requestType: upload` |
/// | `parameters` 有值 | `parameters` 返回 null |
/// | 标准 `{ code, msg, data }` 响应 | 可能需要 override `decodeResponse` |
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
/// 文件上传响应 DTO只需反序列化禁止生成无用的 toJson
@JsonSerializable(createToJson: false)
class UploadResult {
final String url;
@JsonKey(name: 'file_id')
final String fileId;
const UploadResult({required this.url, required this.fileId});
factory UploadResult.fromJson(Map<String, dynamic> json) =>
_$UploadResultFromJson(json);
}
// ═════════════════════════════════════════════
// 模式 A: FormData 上传到自有后端
// ═════════════════════════════════════════════
/// FormData 上传请求
///
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封。
/// 无需 override `decodeResponse`。
@ApiRequest(
path: ApiPaths.uploadFile,
method: HttpMethod.post,
responseType: UploadResult,
requestType: ApiRequestType.upload,
)
class UploadFileRequest extends ApiRequestable<UploadResult>
with _$UploadFileRequestApi {
final String filePath;
final String? fileName;
UploadFileRequest({required this.filePath, this.fileName});
@override
Map<String, dynamic> toJson() => {};
/// FormData — SDK 通过 uploadData 获取上传数据
@override
Object? get uploadData {
return FormData.fromMap({
'file': MultipartFile.fromFileSync(filePath, filename: fileName),
});
}
}
// ═════════════════════════════════════════════
// 模式 B: 二进制上传到 S3 presigned URL
// ═════════════════════════════════════════════
/// S3 presigned URL 上传响应
class S3UploadResponse {
final bool success;
final String? message;
const S3UploadResponse({this.success = true, this.message});
}
/// S3 presigned URL 上传请求
///
/// 特点:
/// - path 为完整的 presigned URLSDK 检测到 http 开头不拼 baseURL
/// - uploadData 为 Uint8List 二进制数据
/// - 自定义 headersContent-Type: application/octet-stream
/// - override decodeResponse — S3 返回 204 No Content 或 XML不是标准信封
class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
final Uint8List data;
final String presignedURL;
S3UploadRequest({required this.data, required this.presignedURL});
@override
String get path => presignedURL;
@override
HttpMethod get method => HttpMethod.put;
@override
ApiRequestType get requestType => ApiRequestType.upload;
@override
Map<String, String>? get customHeaders => {
'Content-Type': 'application/octet-stream',
};
@override
Map<String, dynamic> toJson() => {};
/// 二进制数据
@override
Object? get uploadData => data;
/// S3 响应不走标准 { code, message, data } 信封,需要自定义解码
///
/// 可能的响应:
/// - 204 No Content空 body→ 成功
/// - 200 + XML body → 成功
/// - 200 + JSON body → 尝试解码
@override
S3UploadResponse? decodeResponse(Response response) {
// 空响应或 2xx 状态码 → 成功
if (response.data == null ||
(response.data is List && (response.data as List).isEmpty)) {
return const S3UploadResponse(success: true);
}
// JSON 响应 → 尝试解码
if (response.data is Map<String, dynamic>) {
final json = response.data as Map<String, dynamic>;
return S3UploadResponse(
success: true,
message: json['message'] as String?,
);
}
// 2xx 状态码 → 成功
if (response.statusCode != null &&
response.statusCode! >= 200 &&
response.statusCode! < 300) {
return const S3UploadResponse(success: true);
}
return const S3UploadResponse(success: true);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:networks_sdk/networks_sdk.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart';
import '../remote/login_request.dart';
import '../remote/logout_request.dart';
/// 认证 Repository 实现
///
/// implements [AuthRepository] 接口domain/repositories/ 中定义)。
/// 直接使用 [ApiClient] 发送请求,将 DTO 转为 Domain Entity。
/// 后续可加 Local DataSource 实现离线缓存。
///
/// ## 数据流位置
///
/// ```
/// LoginUseCase.execute(email, password)
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
/// → ApiClient.executeRequest(LoginRequest)
/// → 服务端 POST /auth/login
/// ← LoginDataResponse DTO
/// → onTokenUpdate(token) ← 回调写入 Token
/// ← LoginData.toEntity() → User ← DTO → Entity 转换在这里
/// ← UserDomain Entity
/// ```
class AuthRepositoryImpl implements AuthRepository {
final NetworksSdkApi _client;
final void Function(String?) _onTokenUpdate;
AuthRepositoryImpl({required NetworksSdkApi client, required void Function(String?) onTokenUpdate,}) : _client = client, _onTokenUpdate = onTokenUpdate;
@override
Future<User> login({required String email, required String password,}) async
{
final LoginData? loginData = await _client.executeRequest(LoginRequest(email: email, password: password),);
if (loginData == null) {
throw Exception('Login failed: empty response'); // TODO: 接入国际化
}
// 回调写入 Token内存 + 持久化由 Provider 层组合)
_onTokenUpdate(loginData.token);
return loginData.toEntity(); // DTO → Domain Entity
}
@override
Future<User?> getCurrentUser() async {
// TODO: 从本地存储获取用户信息
return null;
}
@override
Future<void> logout() async {
await _client.executeRequest(LogoutRequest());
_onTokenUpdate(null); // 回调清除 Token内存 + 持久化由 Provider 层组合)
}
}

View File

@@ -0,0 +1,28 @@
/// 用户 Domain 实体
///
/// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。
/// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。
///
/// ## 数据流位置
///
/// ```
/// 服务端 JSON
/// → LoginDataResponse DTOdata/remote/login_request.dart
/// → LoginData.toEntity()
/// → ★ User ★ ← 你在这里
/// → ViewModel.state
/// → View 渲染
/// ```
class User {
final String id;
final String email;
final String? nickname;
final String? avatar;
const User({
required this.id,
required this.email,
this.nickname,
this.avatar,
});
}

View File

@@ -0,0 +1,26 @@
import '../entities/user.dart';
/// 认证 Repository 接口(依赖倒置)
///
/// Domain 层定义 WhatData 层实现 How。
/// ViewModel 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
///
/// ## 数据流位置
///
/// ```
/// ViewModel
/// → ★ AuthRepository.login() ★ ← 你在这里(接口)
/// → AuthRepositoryImpl.login() ← data/repositories/(实现)
/// → _client.executeRequest(LoginRequest)
/// → 服务端
/// ```
abstract interface class AuthRepository {
/// 登录,返回 Domain Entity [User]
Future<User> login({required String email, required String password});
/// 获取当前登录用户信息
Future<User?> getCurrentUser();
/// 退出登录
Future<void> logout();
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 主 Tab 容器Shell 层)
///
/// 由 [StatefulShellRoute.indexedStack] 驱动,不持有任何状态。
/// Tab 切换通过 [navigationShell.goBranch] 完成go_router 负责保持各 Tab 的导航栈。
///
/// Tabsindex 顺序):
/// - 0聊天
/// - 1联系人
/// - 2设置
class AppTab extends StatelessWidget {
const AppTab({
super.key,
required this.navigationShell,
});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(
index,
// 再次点击已激活的 Tab 时回到该 Tab 的初始路由
initialLocation: index == navigationShell.currentIndex,
),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.chat_bubble_outline),
activeIcon: Icon(Icons.chat_bubble),
label: '聊天',
),
BottomNavigationBarItem(
icon: Icon(Icons.people_outline),
activeIcon: Icon(Icons.people),
label: '联系人',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: '设置',
),
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart';
import '../../../app/router/app_route_name.dart';
part 'chat_view_model.g.dart';
/// 聊天页 ViewModel@riverpod 自动生成 `chatViewModelProvider`
///
/// 当前 chat 页面为 Demo无需从服务端加载数据状态为 void。
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
///
/// ## 数据流
///
/// ```
/// ChatPage
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
/// → ★ ChatViewModel ★ ← 你在这里
/// → 导航 / 业务逻辑
/// ```
@riverpod
class ChatViewModel extends _$ChatViewModel {
@override
void build() {}
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
/// 切换到联系人 Tab。
void goToContact(BuildContext context) {
context.go(AppRouteName.contact.path);
}
/// 带 extra 参数 push 聊天详情页extra 传 Dart Record
void pushChatDetailWithExtra(BuildContext context) {
context.push(
AppRouteName.chatDetail.path,
extra: (conversationId: '42', title: 'extra 传参'),
);
}
/// 带路径参数 push 聊天详情页id 内嵌在 URL 中)。
void pushChatDetailById(BuildContext context) {
context.push(AppRouteName.chatDetailByIdPath('99'));
}
/// 无参 push演示 push 导航)。
void pushSettingsTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path);
}
/// 切换到设置 Tab。
void goToSettings(BuildContext context) {
context.go(AppRouteName.settings.path);
}
// ── 业务 ─────────────────────────────────────────────────────────────────
/// 退出登录
///
/// 调用 [AuthNotifier.logout] 清除登录状态go_router 守卫检测到后
/// 自动重定向到登录页,无需手动导航。
void logout() {
ref.read(authNotifierProvider).logout();
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
/// 会话详情页(路由传参 Demo
///
/// 通过 go_router 的 `extra` 接收上一页传入的数据,
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入,
/// 本页不感知 GoRouter 任何实现细节。
///
/// ## 正式开发
///
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
class ChatDetailPage extends StatelessWidget {
const ChatDetailPage({
super.key,
required this.conversationId,
required this.title,
});
final String conversationId;
final String title;
@override
Widget build(BuildContext context) {
final s = context.styles;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('会话 ID', style: s.labelMuted),
Text(conversationId, style: s.headlineSmall),
],
),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/ui/components/app_button.dart';
import '../presentation/chat_view_model.dart';
/// 聊天页Demo 按钮)
///
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
/// - 「切换 Tab」 — go替换历史不可返回
/// - 「有参 pushextra」 — push + extraDart Record可返回
/// - 「有参 push路径参数」— push + URL 内嵌 id可返回
/// - 「无参 push」 — push可返回
/// - 「退出登录」 — 守卫自动重定向到 /login
///
/// 所有操作通过 [ChatViewModel] 处理View 不直接调用路由。
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
class ChatPage extends ConsumerWidget {
const ChatPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(chatViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('聊天')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
// 切换 Tab用 go替换整个历史栈不可返回
AppButton.inverse(
label: '切换 Tabgo',
onPressed: () => vm.goToContact(context),
),
// 带参数 pushextra 传 Dart Record适合已有对象的场景
AppButton.inverse(
label: '有参 pushextra',
onPressed: () => vm.pushChatDetailWithExtra(context),
),
// 带参数 pushid 内嵌在路径中,适合需要深链接 / 分享的场景
AppButton.inverse(
label: '有参 push路径参数',
onPressed: () => vm.pushChatDetailById(context),
),
// 无参 push压栈自动显示返回按钮不切 Tab
AppButton.inverse(
label: '无参 push',
onPressed: () => vm.pushSettingsTheme(context),
),
// 无参 go替换历史切换到对应 TabTabBar 可见,不可返回
AppButton.inverse(
label: '无参 go',
onPressed: () => vm.goToSettings(context),
),
AppButton.secondary(
label: '退出登录',
fullWidth: false,
onPressed: () => vm.logout(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
/// 联系人页占位
///
/// 待 contact 功能开发后替换为实际内容。
class ContactPage extends StatelessWidget {
const ContactPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold();
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/di/network_provider.dart';
import '../../../app/di/db_provider.dart';
import '../../../data/repositories/auth_repository_impl.dart';
import '../../../domain/repositories/auth_repository.dart';
import '../usecases/login_usecase.dart';
/// ## DI 装配Auth Feature 层
///
/// di/ 目录只放**需要手动装配的 Provider**(构造注入、回调组合等)。
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
///
/// Auth 模块的 DI 链路Repository → UseCase按需
/// app/di/ 只提供 SDK 基础设施apiConfig / apiClient / socketManager / storageApi
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
///
/// ```
/// LoginViewModel ← @riverpod 自动生成
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
/// ```
// ── Repository ────────────────────────────────────────────────────────────────
/// 认证 Repository Provider
///
/// 注入 domain 接口类型 [AuthRepository]
/// ViewModel 通过此 Provider 获取依赖,不感知具体实现。
///
/// [onTokenUpdate] 是复合回调:
/// 1. 写入 ApiConfig 内存 → 后续请求自动携带 token
/// 2. TODO: 持久化到安全存储crypto_sdk→ App 重启后恢复
/// 两个 SDK 互不依赖,由 App 层在此组合。
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final apiConfig = ref.read(apiConfigProvider);
// TODO: final secureStorage = ref.read(secureStorageProvider);
return AuthRepositoryImpl(
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存network_sdk
// TODO: secureStorage.saveToken(token); // 持久化crypto_sdk
},
);
});
// ── UseCase ───────────────────────────────────────────────────────────────────
/// 登录用例 Provider
///
/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
return LoginUseCase(
authRepository: ref.read(authRepositoryProvider),
socketManager: ref.read(socketManagerProvider),
apiConfig: ref.read(apiConfigProvider),
storageApi: ref.read(storageSdkProvider),
);
});

View File

@@ -0,0 +1,33 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../domain/entities/user.dart';
part 'login_state.freezed.dart';
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString
///
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
/// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。
///
/// ## 状态流转
///
/// ```
/// 初始 → LoginState() isLoading: false, user: null, error: null
/// 点击登录 → state.copyWith(isLoading: true) isLoading: true
/// 登录成功 → state.copyWith(user: user) isLoading: false, user: User
/// 格式错误 → state.copyWith(error: '邮箱格式不正确') isLoading: false, error: String
/// 网络错误 → state.copyWith(error: '网络错误') isLoading: false, error: String
/// ```
@freezed
sealed class LoginState with _$LoginState {
const factory LoginState({
/// 登录成功后的用户信息null = 未登录)
User? user,
/// 是否正在请求中(控制 loading 状态 / 按钮禁用)
@Default(false) bool isLoading,
/// 错误信息null = 无错误)
String? error,
}) = _LoginState;
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/foundation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../../app/di/app_providers.dart';
import '../di/auth_providers.dart';
import 'login_state.dart';
part 'login_view_model.g.dart';
/// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider`
///
/// `@riverpod` 注解 → build_runner 自动生成 `login_view_model.g.dart`
/// 其中包含 `loginViewModelProvider`。View 层直接使用:
///
/// ```dart
/// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider);
///
/// // View 层调用方法
/// ref.read(loginViewModelProvider.notifier).login(email, password);
/// ```
///
/// ## 手动 vs 自动 Provider 对比
///
/// ```
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// ```
///
/// ## 数据流位置
///
/// ```
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
/// → ★ LoginViewModel.login() ★ ← 你在这里
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
/// → AuthRepository.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginData → User
/// ← User
/// → state = state.copyWith(user: user) ← 更新状态
/// View: ref.watch → 自动 rebuild ← UI 刷新
/// ```
@riverpod
class LoginViewModel extends _$LoginViewModel {
@override
LoginState build() => const LoginState();
/// Demo 登录(跳过 API直接设置登录状态
///
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future<void> demoLogin() async {
final storageApi = ref.read(storageSdkProvider);
///TODO: StorageSDKLifeCycle 需要只在主项目暴露
final storageLifeCycle = storageApi as StorageSdkLifecycle;
ref.read(authNotifierProvider).login();
await storageLifeCycle.openDatabase(1234567);
final rows = await storageApi.rawQuery("PRAGMA table_info('user')");
for (final row in rows) {
debugPrint('Schema: ${row.data}');
}
}
/// 执行登录
///
/// 1. 设置 loading 状态UI 显示加载指示器、禁用按钮)
/// 2. 调 UseCase格式校验 → 登录 → 返回 User
/// 3. 成功:写入 user失败写入 error
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
try {
final user = await ref.read(loginUseCaseProvider).execute(
email: email,
password: password,
);
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) {
// 格式校验失败UseCase 层抛出)
state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) {
// 网络 / 服务端错误Repository → SDK 透传)
state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) {
// 兜底:防止未预期的异常导致 isLoading 死锁
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
}

View File

@@ -0,0 +1,117 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../../core/services/socket_manager.dart';
import '../../../domain/entities/user.dart';
import '../../../domain/repositories/auth_repository.dart';
/// 登录用例
///
/// 封装登录的完整业务流程:
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
///
/// ## 为什么需要 UseCase
///
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
/// - 格式校验(不发无效请求,省流量、减少服务端压力)
/// - 登录后初始化 WebSocket 连接
/// - 登录后按 user id 打开对应的本地数据库
///
/// 把这些规则封装在 UseCase 里ViewModel 只需一行调用。
///
/// ## 数据流位置
///
/// ```
/// LoginViewModel.login(email, password)
/// → ★ LoginUseCase.execute() ★ ← 你在这里
/// → 格式校验(邮箱 + 密码)
/// → AuthRepository.login()
/// → AuthRepositoryImpl.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginDataDTO
/// → _onTokenUpdate(token) ← 回调写入 Token内存 + 持久化,由 Provider 层组合)
/// ← LoginData.toEntity() → User
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
/// ← User
/// ```
class LoginUseCase {
final AuthRepository _authRepository;
final SocketManager _socketManager;
final ApiConfig _apiConfig;
final StorageSdkApi _storageApi;
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
LoginUseCase({
required AuthRepository authRepository,
required SocketManager socketManager,
required ApiConfig apiConfig,
required StorageSdkApi storageApi,
}) : _authRepository = authRepository,
_socketManager = socketManager,
_apiConfig = apiConfig,
_storageApi = storageApi;
/// 执行登录
///
/// 1. 格式校验 → 不合法直接抛 [FormatException]
/// 2. 调 Repository 登录 → 拿到 Usertoken 写入由 Repository 处理)
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
/// 4. 按 user id 打开本地数据库
///
/// 抛出:
/// - [FormatException] — 邮箱或密码格式不合法
/// - [ApiError] — 网络/服务端错误(由 Repository 透传)
Future<User> execute({
required String email,
required String password,
}) async {
// ── 1. 格式校验 ──
_validateEmail(email);
_validatePassword(password);
// ── 2. 登录 ──
final user = await _authRepository.login(
email: email,
password: password,
);
// ── 3. 连接 WebSocket ──
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig
// 此处直接读取,避免改动现有接口。
final token = _apiConfig.token;
if (token != null && token.isNotEmpty) {
await _socketManager.connect(token: token);
}
// ── 4. 打开数据库 ──
// TODO: 当服务端返回整型 uid 时,换成 user.uid目前用 hashCode 作为临时标识。
await _storageLifeCycle.openDatabase(user.hashCode);
// TODO: 后续扩展点
// - 同步联系人列表
// - 注册推送 token
return user;
}
void _validateEmail(String email) {
if (email.trim().isEmpty) {
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
}
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(email.trim())) {
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
}
}
void _validatePassword(String password) {
if (password.isEmpty) {
throw const FormatException('密码不能为空'); // TODO: 接入国际化
}
if (password.length < 6) {
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
import '../presentation/login_view_model.dart';
/// 登录页Demo
///
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin]
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
///
/// 正式实现时替换为完整登录流程email/password 输入 → LoginViewModel.login
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = context.styles;
return Scaffold(
appBar: AppBar(
title: const Text('登录'),
automaticallyImplyLeading: false,
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('IM_Demo', style: s.titleMedium),
const SizedBox(height: 8),
Text(
'未登录时任意路由均被重定向到此页 \n 主要是为了展示路由守卫的功能 \n 后续路由守卫专门处理各种跳转前的逻辑判断',
style: s.bodySmall,
),
const SizedBox(height: 32),
FilledButton(
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
child: const Text('登录'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../usecases/set_theme_usecase.dart';
/// Settings feature DI 装配
///
/// 手动装配 UseCase ProviderViewModel 通过此处获取依赖。
///
/// ```
/// ThemeViewModel
/// → ref.read(setThemeUseCaseProvider) ← 此处装配
/// → SetThemeUseCase幂等校验
/// → onApply → ThemeModeNotifier.setMode()(内存 + 持久化 TODO
/// ```
// ── UseCase ───────────────────────────────────────────────────────────────────
/// 设置主题用例 Provider
final setThemeUseCaseProvider = Provider<SetThemeUseCase>(
(_) => const SetThemeUseCase(),
);

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/router/app_route_name.dart';
part 'settings_view_model.g.dart';
/// 设置页 ViewModel
///
/// ## 数据流位置
///
/// ```
/// SettingsPage
/// → ref.read(settingsViewModelProvider.notifier).navigateToTheme(context)
/// → ★ SettingsViewModel.navigateToTheme() ★ ← 你在这里
/// → context.push(AppRouteName.settingsTheme.path)
/// ```
///
/// 导航意图由 ViewModel 统一管理View 不直接调用路由。
@riverpod
class SettingsViewModel extends _$SettingsViewModel {
@override
void build() {}
/// 跳转到主题设置页。
void navigateToTheme(BuildContext context) {
context.push(AppRouteName.settingsTheme.path);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart';
import '../di/settings_providers.dart';
part 'theme_view_model.g.dart';
/// 主题 ViewModel
///
/// View 层只感知此 ViewModel不直接依赖 app 级 Provider。
///
/// ## 数据流
///
/// ```
/// ThemeView
/// → ref.watch(themeViewModelProvider) ← 当前 ThemeMode
/// → ref.read(themeViewModelProvider.notifier).setMode(mode)
/// → ★ ThemeViewModel.setMode() ★ ← 你在这里
/// → SetThemeUseCase.execute()
/// → 幂等校验(相同模式直接返回)
/// → onApply → ThemeModeNotifier.setMode() ← 更新内存状态
/// → TODO: 持久化storage_sdk
/// ```
@riverpod
class ThemeViewModel extends _$ThemeViewModel {
@override
ThemeMode build() => ref.watch(themeModeProvider);
void setMode(ThemeMode mode) {
ref.read(setThemeUseCaseProvider).execute(
current: state,
requested: mode,
onApply: (m) => ref.read(themeModeProvider.notifier).setMode(m),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// 设置主题用例
///
/// 职责:幂等校验——当前模式与目标模式相同时直接返回,不触发任何变更。
///
/// 持久化由 [ThemeModeNotifier.setMode] 负责(在 `onApply` 被调用后执行),
/// UseCase 不感知存储细节。
///
/// ## 数据流
///
/// ```
/// ThemeViewModel.setMode(mode)
/// → ★ SetThemeUseCase.execute() ★ ← 你在这里
/// → 幂等校验(相同模式 → 直接返回)
/// → onApply(mode)
/// → ThemeModeNotifier.setMode() ← 更新内存 + 写入持久化TODO
/// ```
class SetThemeUseCase {
const SetThemeUseCase();
/// 执行主题切换
///
/// [current] 当前生效的主题模式
/// [requested] 用户选择的目标模式
/// [onApply] 校验通过后回调,由 ViewModel 负责调用 ThemeModeNotifier
void execute({
required ThemeMode current,
required ThemeMode requested,
required void Function(ThemeMode mode) onApply,
}) {
if (current == requested) return;
onApply(requested);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/settings_view_model.dart';
/// 设置页
///
/// 所有用户操作通过 [SettingsViewModel] 处理View 不直接调用路由。
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
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),
),
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/theme_view_model.dart';
import 'widgets/settings_section_header.dart';
import 'widgets/theme_option_tile.dart';
/// 主题选择页
///
/// 通过 [ThemeViewModel] 读写主题状态,不直接感知 app 级 Provider。
class ThemeView extends ConsumerWidget {
const ThemeView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(themeViewModelProvider);
return Scaffold(
appBar: AppBar(
title: const Text('主题'),
),
body: ListView(
children: [
const SettingsSectionHeader(title: '外观'),
ThemeOptionTile(
label: '跟随系统',
mode: ThemeMode.system,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.system),
),
ThemeOptionTile(
label: '黑色模式',
mode: ThemeMode.dark,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.dark),
),
ThemeOptionTile(
label: '白色模式',
mode: ThemeMode.light,
current: current,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.light),
),
],
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart';
/// 设置页分组标题
///
/// 用于在列表中区分配置分组,如「外观」、「通知」。
/// 文字颜色跟随 [ColorScheme.primary],自带上下留白。
///
/// 用法:
/// ```dart
/// const SettingsSectionHeader(title: '外观')
/// ```
class SettingsSectionHeader extends StatelessWidget {
const SettingsSectionHeader({
super.key,
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
final s = context.styles;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 4),
child: Text(title, style: s.sectionLabel),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart';
/// 单个主题选项行
///
/// 纯展示 + 事件透传,不感知任何 Provider。
/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。
///
/// 用法:
/// ```dart
/// ThemeOptionTile(
/// label: '黑色模式',
/// mode: ThemeMode.dark,
/// current: current,
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
/// )
/// ```
class ThemeOptionTile extends StatelessWidget {
const ThemeOptionTile({
super.key,
required this.label,
required this.mode,
required this.current,
required this.onTap,
});
final String label;
final ThemeMode mode;
final ThemeMode current;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = context.styles;
final isSelected = current == mode;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: s.primary) : null,
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,5 @@
import 'package:im_app/app/bootstrap.dart';
void main() {
bootstrap();
}