Merge branch 'dev' into happi/dev/database-update

# Conflicts:
#	apps/im_app/lib/data/models/user_dto.dart
#	apps/im_app/lib/data/remote/login_request.dart
#	apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
#	apps/im_app/lib/features/chat/view/chat_db_test_page.dart
#	apps/im_app/lib/features/login/presentation/login_view_model.dart
This commit is contained in:
Happi (哈比)
2026-03-09 15:08:45 +08:00
163 changed files with 4341 additions and 1785 deletions

View File

@@ -23,11 +23,18 @@ class AuthNotifier extends ChangeNotifier {
void login() {
_isLoggedIn = true;
// TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入:
// 1. 从安全存储keychain / secure storage读取公私钥对只读一次
// 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem)
// 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。
notifyListeners();
}
void logout() {
_isLoggedIn = false;
// TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥:
// cipherSdk.clearActiveKeyPair()
// cipherSdk.clearDerivedKeyCache()
notifyListeners();
}
}
@@ -37,9 +44,7 @@ class AuthNotifier extends ChangeNotifier {
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier]ChangeNotifier
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
final authNotifierProvider = Provider<AuthNotifier>(
(ref) => AuthNotifier(),
);
final authNotifierProvider = Provider<AuthNotifier>((ref) => AuthNotifier());
// ── 主题 ──────────────────────────────────────────────────────────────────────

View File

@@ -6,6 +6,8 @@ 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/foundation/errors.dart';
import '../../core/foundation/utils.dart';
import '../../core/services/network_monitor.dart';
import '../../core/services/socket_manager.dart';
@@ -47,6 +49,21 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
return monitor;
});
// ── Token 更新事件流 ─────────────────────────────────────────────────────────
/// Token 更新事件流
///
/// apiConfigProvider.onTokenUpdated → 推送新 token 到此流
/// socketManagerProvider → 监听此流 → 同步 token 到 WebSocket
/// onBeforeReconnect 中刷新 token 后调用 apiConfig.updateToken → tokenStream.add
/// 需要同步传播到 socketManager.updateToken → socketClient._currentToken
/// 确保随后的 _doConnect() 使用新 token。异步模式下 _doConnect 会在 stream
final _tokenUpdateStreamProvider = Provider<StreamController<String>>((ref) {
final controller = StreamController<String>.broadcast(sync: true);
ref.onDispose(controller.close);
return controller;
});
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
/// API 配置 Provider全局单例
@@ -58,15 +75,18 @@ final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
final apiConfigProvider = Provider<ApiConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
final tokenStream = ref.read(_tokenUpdateStreamProvider);
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
'Platform': 'Android', // TODO: 运行时从平台 API 获取
'Platform': 'Android', // TODO: 运行时从 platform API 获取
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
'Channel': '', // TODO: 从 AppConfig 读取渠道标识
'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
},
tokenExpiredCodes: {30002, 30003, 30124},
forceLogoutCodes: {30125},
tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
onForceLogout: () {
// TODO: 清除登录态,跳转登录页
},
@@ -74,7 +94,33 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
// TODO: App 层刷新 token 逻辑
return null;
},
onTokenUpdated: (newToken) {
// 通过事件流同步到 WebSocket避免直接引用 socketManagerProvider 造成循环依赖
tokenStream.add(newToken);
},
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
// TODO: 接入 cipher_guard_sdk 后注入请求加密回调。
// 前提AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。
// 示例:
// onEncryptRequest: (path, headers, body) async {
// final encryptedKey = await cipherSdk.encryptSessionKeyWithActiveKey(
// sessionKey: currentSessionKey,
// );
// return EncryptedRequest(body: encryptedBody, headers: {'X-Key': encryptedKey});
// },
onEncryptRequest: null,
// TODO: 接入 cipher_guard_sdk 后注入响应解密回调。
// 前提:与 onEncryptRequest 配套,服务端响应同样加密时启用。
// 示例:
// onDecryptResponse: (data) async {
// final plaintext = await cipherSdk.decryptMessage(encryptedData: data as String, ...);
// return jsonDecode(plaintext) as Map<String, dynamic>;
// },
onDecryptResponse: null,
onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等)
onTransformResponse:
null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
onGetTokenExpiry: parseJwtExpiry,
maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay,
onLog: (message, {tag}) {
@@ -94,16 +140,47 @@ final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
/// SocketConfig Provider全局单例
/// SocketConfig Provider内部使用,不对外暴露
///
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
/// SDK 内部不调用其他 SDK。
final socketConfigProvider = Provider<SocketConfig>((ref) {
final _socketConfigProvider = Provider<SocketConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay,
unlimitedReconnect: true, // IM 场景始终保持连接
onBuildConnectUrl:
null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数)
onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调
onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调
onBeforeReconnect: () async {
// SocketClient 内部重连心跳超时、stream onDone前调用。
// 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。
// 刷新后通过 sync stream 同步传播到 SocketClient._currentToken
// 确保随后的 _doConnect() 使用新 token。
final apiConfig = ref.read(apiConfigProvider);
final currentToken = apiConfig.token;
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
if (expiry == null) return;
final remaining = expiry.difference(DateTime.now());
if (remaining > apiConfig.proactiveRefreshThreshold) return;
// ignore: avoid_print
print(
'[Socket] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
);
final newToken = await apiConfig.onTokenRefresh?.call();
if (newToken != null && newToken.isNotEmpty) {
// updateToken → onTokenUpdated → sync stream → manager.updateToken
// → _client.updateToken → socketClient._currentToken 同步更新
apiConfig.updateToken(newToken);
}
},
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Socket'}] $message');
@@ -114,12 +191,11 @@ final socketConfigProvider = Provider<SocketConfig>((ref) {
);
});
/// SocketClient Provider全局单例
/// SocketClient Provider内部使用,不对外暴露
///
/// 与 apiClientProvider 对称。
final socketClientProvider = Provider<NetworksMessagingApi>((ref)
{
final config = ref.read(socketConfigProvider);
/// 与 networkSdkApiProvider 对称。
final _socketClientProvider = Provider<NetworksMessagingApi>((ref) {
final config = ref.read(_socketConfigProvider);
return NetworksMessagingApi()..initialize(config);
});
@@ -139,17 +215,44 @@ final socketClientProvider = Provider<NetworksMessagingApi>((ref)
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
/// 自动触发断连/重连。
///
/// Token 更新由 [_tokenUpdateStreamProvider] 事件流驱动,
/// HTTP 层刷新 token 后自动同步到 WebSocket。
///
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
/// 后续接入加解密 SDK 时,在此注入解密回调,
/// SDK 内部不调用其他 SDK。
final socketManagerProvider = Provider<SocketManager>((ref) {
final client = ref.read(socketClientProvider);
final client = ref.read(_socketClientProvider);
final networkMonitor = ref.read(networkMonitorProvider);
final apiConfig = ref.read(apiConfigProvider);
final tokenStream = ref.read(_tokenUpdateStreamProvider);
final manager = SocketManager(
client: client,
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
onBeforeReconnect: () async {
// 重连前检查 token 是否即将过期,是则主动刷新
final currentToken = apiConfig.token;
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
if (expiry == null) return;
final remaining = expiry.difference(DateTime.now());
if (remaining > apiConfig.proactiveRefreshThreshold) return;
// ignore: avoid_print
print(
'[SocketManager] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
);
final newToken = await apiConfig.onTokenRefresh?.call();
if (newToken != null && newToken.isNotEmpty) {
// updateToken 触发 onTokenUpdated → tokenStream → socketManager.updateToken
apiConfig.updateToken(newToken);
}
},
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
onLog: (message, {tag}) {
// ignore: avoid_print
@@ -157,13 +260,19 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
},
);
// 监听 token 更新事件 → 同步到 WebSocket
final tokenSub = tokenStream.stream.listen((newToken) {
manager.updateToken(newToken);
});
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
final networkSub = networkMonitor.onStatusChanged.listen((isAvailable) {
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
});
ref.onDispose(() {
subscription.cancel();
tokenSub.cancel();
networkSub.cancel();
unawaited(manager.dispose());
});
@@ -215,23 +324,57 @@ String _buildWsUrl(String httpBaseUrl) {
// Provider 链路:
//
// networkMonitorProvider公共服务HTTP + WS 共用)
// ├── apiConfigProvider → apiClientProvider ← HTTP 层
// └── socketConfigProvider → socketClientProvider ← WS 层
// ├── apiConfigProvider → networkSdkApiProvider ← HTTP 层
// └── _socketConfigProvider → _socketClientProvider ← WS 层(内部)
// → socketManagerProvider
//
// _tokenUpdateStreamProvider打破循环引用的中间层
// ← apiConfigProvider.onTokenUpdated 推送
// → socketManagerProvider 监听 → socketManager.updateToken()
//
// 网络事件驱动链路:
//
// connectivity_plus平台网络事件
// → NetworkMonitor.onStatusChangedtrue / false
// → SocketManager.handleNetworkStatusChanged()
// → 断网: disconnect()
// → 恢复: connect(token: lastToken)
// → 恢复: onBeforeReconnect → connect(token: lastToken)
//
// 前后台事件驱动链路:
//
// WidgetsBindingObserverApp 层 app.dart
// → SocketManager.onEnterBackground() → disconnect
// → SocketManager.onEnterForeground() → reconnect
// → SocketManager.onEnterBackground()
// disconnectInBackground=false → 完全保活,心跳不停(本项目默认)
// disconnectInBackground=true → disconnect + 暂停心跳(省电模式)
// → SocketManager.onEnterForeground()
// 保活模式 → 检查连接健康,异常则重连
// 断连模式 → onBeforeReconnect → reconnect
//
// Token 刷新 → WebSocket 同步链路:
//
// RetryInterceptor 检测 token 过期
// → TokenRefreshManager.refreshIfNeeded()
// → apiConfig.updateToken(newToken)
// → onTokenUpdated(newToken)
// → _tokenUpdateStream.add(newToken)
// → socketManager.updateToken(newToken) // 不断连,下次重连自动用新 token
//
// 主动 token 刷新(重连前,两个层级):
//
// SocketManager 层(前台恢复 / 网络恢复触发):
// SocketManager.onBeforeReconnect()
// → 解析 JWT exp → 距过期 < 阈值
// → apiConfig.onTokenRefresh() → 刷新
// → apiConfig.updateToken(newToken)
// → sync stream → manager.updateToken → _lastToken 更新
// → _client.connect(token: _lastToken) 使用新 token
//
// SocketClient 层(心跳超时 / stream onDone 触发):
// SocketConfig.onBeforeReconnect()
// → 同上逻辑:检查 JWT exp → 刷新 → apiConfig.updateToken
// → sync stream → manager.updateToken → _client.updateToken
// → socketClient._currentToken 同步更新
// → _doConnect() 使用新 token
//
// Repository 直接注入 ApiClient通过回调注入其他 SDK 能力:
//
@@ -313,7 +456,7 @@ String _buildWsUrl(String httpBaseUrl) {
// final authRepositoryProvider = Provider((ref) {
// final apiConfig = ref.read(apiConfigProvider);
// return AuthRepositoryImpl(
// client: ref.read(apiClientProvider), // 直接注入
// client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存network_sdk
// // secureStorage.saveToken(token); // 持久化crypto_sdk
@@ -400,5 +543,5 @@ String _buildWsUrl(String httpBaseUrl) {
// Upload B: 二进制上传到 S3 presigned URL
// @override String get path => presignedURL; // 完整 URL不拼 baseURL
// @override Object? get uploadData => bytes; // Uint8List
// @override decodeResponse(response) { ... } // S3 不走标准信封
// @override decodeResponse(response) { ... } // S3 不走标准响应格式
//

View File

@@ -0,0 +1,57 @@
/// API 错误码常量
///
/// 集中管理后端业务错误码,避免散落在各处硬编码。
/// 按业务域分组,命名风格对齐后端定义。
///
/// 使用方式:
/// ```dart
/// ApiConfig(
/// tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
/// forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
/// )
/// ```
class ApiErrorCodes {
ApiErrorCodes._();
// ── 认证30001-30009──
/// Token 无效
static const int tokenInvalid = 30002;
/// JWT 无效
static const int jwtInvalid = 30003;
/// 签名方法错误
static const int signingMethodError = 30008;
/// 密钥解析失败
static const int parsingKeyError = 30009;
/// Session 无效
static const int sessionInvalid = 30124;
/// Refresh Token 失效
static const int refreshTokenFailed = 30125;
/// 账号在其他设备登录
static const int loggedInAnotherDevice = 30006;
// ── 错误码集合 ──
/// Token 过期错误码集合 — 触发自动刷新 Token
static const Set<int> tokenExpiredCodes = {
tokenInvalid,
jwtInvalid,
sessionInvalid,
};
/// 强制登出错误码集合 — 触发退出登录流程
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
/// 踢下线错误码集合 — 触发踢下线 UI 提示
static const Set<int> kickOffCodes = {
loggedInAnotherDevice,
signingMethodError,
parsingKeyError,
};
}

View File

@@ -0,0 +1,32 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
/// JWT token 过期时间解析
///
/// 使用 dart_jsonwebtoken 解码 JWT payload提取 `exp` claim 返回过期时间。
/// 返回 null 表示无法解析(非 JWT 格式或缺少 exp 字段)。
///
/// 只读取 payload不验证签名验证是服务端的事
///
/// 用于 [ApiConfig.onGetTokenExpiry] 回调,启用 token 主动刷新:
/// 距过期不足阈值时提前刷新,避免带过期 token 发请求或重连。
///
/// ```dart
/// final expiry = parseJwtExpiry('eyJhbGci...');
/// if (expiry != null) {
/// final remaining = expiry.difference(DateTime.now());
/// print('Token expires in ${remaining.inMinutes} min');
/// }
/// ```
DateTime? parseJwtExpiry(String token) {
try {
final jwt = JWT.decode(token);
final payload = jwt.payload;
if (payload is! Map<String, dynamic>) return null;
final exp = payload['exp'];
if (exp is! int) return null;
return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
} catch (_) {
return null;
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:networks_sdk/networks_sdk.dart';
import 'network_backoff_debouncer.dart';
@@ -10,15 +9,14 @@ import 'network_backoff_debouncer.dart';
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
/// App 层在 Provider 装配时注入解密/解析逻辑,
/// 不在 SDK 内部调用加解密 SDK。
typedef MessageTransformer = Map<String, dynamic> Function(
Map<String, dynamic> raw,
);
typedef MessageTransformer =
Map<String, dynamic> Function(Map<String, dynamic> raw);
/// WebSocket 连接管理
///
/// 在 SocketClientSDK 底层能力)之上封装:
/// - 连接/断连生命周期(登录连接、登出断连)
/// - 前后台生命周期(后台断连省电、前台自动重连
/// - 前后台生命周期(两种模式:后台断连 / 后台保活
/// - 网络状态响应(断网断连、恢复网络立即重连)
/// - 操作前置检查(网络可用性 + 后台状态)
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
@@ -39,19 +37,26 @@ typedef MessageTransformer = Map<String, dynamic> Function(
///
/// ```
/// 登录成功 → connect(token) → 前置检查 → 建立连接
/// App 进后台 → onEnterBackground() → 断开连接(省电)
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
///
/// ── disconnectInBackground = true后台断连模式──
/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
///
/// ── disconnectInBackground = false后台保活模式本项目默认──
/// App 进后台 → onEnterBackground() → 不操作,心跳不停、连接不断
/// App 回前台 → onEnterForeground() → 检查连接健康,异常则重连
///
/// 网络丢失 → handleNetworkLost() → 断开连接
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
/// 登出 → disconnect() → 断开连接,清除 token
/// ```
///
/// ## 前置检查策略
///
/// 所有会发起网络操作的方法都先检查前置条件:
/// - connect → 检查网络可用性 + 是否在后台
/// - send / sendString → 检查连接状态 + 是否在后台
/// - onEnterForeground 重连 → 检查网络可用性
/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截)
/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截)
/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect
class SocketManager {
final NetworksMessagingApi _client;
final String _wsUrl;
@@ -70,6 +75,22 @@ class SocketManager {
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
final Future<bool> Function()? onCheckNetworkAvailable;
/// 重连前回调
///
/// 在 WebSocket 重连前调用前台恢复、网络恢复App 层用于:
/// - 检查并刷新即将过期的 token
/// - 更新连接参数
///
/// 回调完成后才发起实际重连。
final Future<void> Function()? onBeforeReconnect;
/// 进后台时是否断开连接
///
/// trueSDK 默认)— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。
/// false本项目使用— 后台保持连接,心跳不停、请求不停,最大程度保活。
/// 回前台时检查连接健康,异常则触发重连。
final bool disconnectInBackground;
/// 日志回调
final void Function(String message, {String? tag})? onLog;
@@ -104,10 +125,12 @@ class SocketManager {
required NetworksMessagingApi client,
required String wsUrl,
this.onMessageTransform,
this.onBeforeReconnect,
this.disconnectInBackground = true,
this.onCheckNetworkAvailable,
this.onLog,
}) : _client = client,
_wsUrl = wsUrl;
}) : _client = client,
_wsUrl = wsUrl;
// ── 连接 ──────────────────────────────────────────────────────────────────
@@ -124,8 +147,8 @@ class SocketManager {
_reconnectOnForeground = false;
_reconnectOnNetworkRestore = false;
// 前置检查:在后台不连接(省电)
if (_isInBackground) {
// 前置检查:后台断连模式下在后台不连接(省电)
if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('In background, defer connect to foreground');
return false;
@@ -165,26 +188,47 @@ class SocketManager {
/// 当前是否在后台
bool get isInBackground => _isInBackground;
/// Token 热更新
///
/// 透传给 SocketClient仅更新内部 token不断开连接。
/// 适用于 HTTP 层 token 刷新后同步到 WebSocket 的场景。
void updateToken(String token) {
_lastToken = token;
_client.updateToken(token);
_log('Token updated via SocketManager');
}
// ── 前后台生命周期 ────────────────────────────────────────────────────────
//
// 后台 → 断连(省电省流量
// 前台 → 自动重连(如果之前有连接)
// 后台 → 保活(心跳不停、连接不断)或断连(省电模式
// 前台 → 检查连接健康 / 自动重连
/// App 进后台 → 断开连接,标记前台恢复时重连
/// App 进后台
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
///
/// [disconnectInBackground] 为 false 时(后台保活,本项目默认):
/// 不断连、不暂停心跳WebSocket 完全保活。
///
/// [disconnectInBackground] 为 true 时(后台断连模式):
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
void onEnterBackground() {
_isInBackground = true;
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
if (!disconnectInBackground) {
// 后台保活模式:不断连、不暂停心跳,不通知 SocketClient
_log('Entering background, keeping connection alive');
return;
}
// 后台断连模式:通知 SocketClient 进后台(暂停心跳)
_client.onEnterBackground();
if (_lastToken == null) return; // 未登录,无需处理
// 与 _handleNetworkLost 保持一致:
// 不仅 connectedconnecting / reconnecting 也要断开,
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
if (_client.isConnected ||
@@ -196,41 +240,76 @@ class SocketManager {
}
}
/// App 回前台 → 自动重连(如果之前后台断连)
/// App 回前台
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
///
/// 后台保活模式disconnectInBackground=false
/// 检查连接健康,如果后台期间连接意外断开则自动重连。
///
/// 后台断连模式disconnectInBackground=true
/// 通知 SocketClient 恢复心跳,然后重新建立连接。
void onEnterForeground() {
_isInBackground = false;
_client.onEnterForeground();
if (disconnectInBackground) {
// 后台断连模式:通知 SocketClient 恢复心跳
_client.onEnterForeground();
}
if (!disconnectInBackground && _lastToken != null) {
// 后台保活模式:检查连接健康
// 虽然后台期间心跳不停,但连接仍可能因网络切换、服务端关闭等原因断开。
// SocketClient 的自动重连在后台也会工作_isBackground=false
// 但回前台时兜底检查一次,确保连接可用。
if (!_client.isConnected) {
_log('Returning to foreground, connection lost, reconnecting...');
_scheduleReconnect();
} else {
_log('Returning to foreground, connection healthy');
}
return;
}
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');
_scheduleReconnect();
}
}
/// 延迟 500ms 后执行重连
///
/// 等待网络稳定,通过 Timer 跟踪以便进后台时取消。
void _scheduleReconnect() {
_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;
}
if (!_client.isConnected && _lastToken != null) {
// 前置检查:网络可用性
if (!await _isNetworkAvailable()) {
_reconnectOnNetworkRestore = true;
_log('Network unavailable, defer reconnect to network restore');
return;
}
// 重连前钩子:刷新即将过期的 token 等
await onBeforeReconnect?.call();
// token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
if (_lastToken != null && !_client.isConnected) {
_client.connect(_wsUrl, token: _lastToken!);
}
},
);
}
}
},
);
}
// ── 网络状态变化 ──────────────────────────────────────────────────────────
@@ -275,18 +354,22 @@ class SocketManager {
if (_reconnectOnNetworkRestore && _lastToken != null) {
_reconnectOnNetworkRestore = false;
// 在后台不重连,等前台恢复时再连
if (_isInBackground) {
// 后台断连模式:在后台不重连,等前台恢复时再连
if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('Network restored but in background, defer to foreground');
return;
}
_log('Network restored, scheduling reconnect with backoff');
_networkDebouncer.call(() {
_networkDebouncer.call(() async {
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
_log('Backoff timer fired, reconnecting');
_client.connect(_wsUrl, token: _lastToken!);
// 重连前钩子:刷新即将过期的 token 等
await onBeforeReconnect?.call();
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
_log('Backoff timer fired, reconnecting');
_client.connect(_wsUrl, token: _lastToken!);
}
}
});
}
@@ -308,6 +391,9 @@ class SocketManager {
/// 原始消息流(不经预处理,调试用)
Stream<String> get rawMessageStream => _client.rawMessageStream;
/// 二进制消息流
Stream<dynamic> get binaryMessageStream => _client.binaryMessageStream;
/// 连接状态变化流
Stream<SocketConnectionState> get connectionStateStream =>
_client.connectionStateStream;
@@ -333,6 +419,14 @@ class SocketManager {
return _client.sendString(message);
}
/// 发送二进制数据
///
/// 前置检查:未连接或在后台时不发送。
Future<bool> sendBytes(List<int> bytes) {
if (!_canSend()) return Future.value(false);
return _client.sendBytes(bytes);
}
// ── 释放 ──────────────────────────────────────────────────────────────────
/// 释放所有资源
@@ -347,7 +441,10 @@ class SocketManager {
/// 发送前置检查
///
/// 两重保险:连接状态 + 后台状态。
/// 后台保活模式disconnectInBackground=false只检查连接状态
/// 后台也能正常发送。
///
/// 后台断连模式disconnectInBackground=true额外检查后台状态
/// 后台已断连所以 isConnected 通常就能拦住,
/// 但显式检查 _isInBackground 防止边界情况遗漏。
bool _canSend() {
@@ -355,8 +452,8 @@ class SocketManager {
_log('Not connected, cannot send');
return false;
}
if (_isInBackground) {
_log('In background, skip send');
if (_isInBackground && disconnectInBackground) {
_log('In background (disconnect mode), skip send');
return false;
}
return true;

View File

@@ -0,0 +1,30 @@
/// 静态资源路径常量,统一维护,避免路径字符串散落在业务代码中。
///
/// 所有路径须与 pubspec.yaml 的 flutter.assets 声明保持一致。
/// 新增资源:① 文件放入 assets/ ② pubspec.yaml 声明 ③ 此处加常量。
///
/// 渲染逻辑(缓存、占位、错误态)由 core/ui/components/ 下的组件负责,不在此处封装。
///
/// ## 使用
/// ```dart
/// Image.asset(AppAssets.logo)
/// Image.asset(AppAssets.logo, width: 80, fit: BoxFit.cover)
/// ```
abstract final class AppAssets {
AppAssets._();
// ── 品牌 ──────────────────────────────────────────────────
static const logo = 'assets/images/logo.png';
static const logoLight = 'assets/images/logo_light.png';
// ── 占位图 ────────────────────────────────────────────────
static const avatarPlaceholder = 'assets/images/avatar_placeholder.png';
// ── 空状态插图SVG引入 flutter_svg 后启用) ─────────────
// static const emptyChat = 'assets/svg/empty_chat.svg';
// static const emptyContact = 'assets/svg/empty_contact.svg';
// static const emptySearch = 'assets/svg/empty_search.svg';
// ── 动画 ──────────────────────────────────────────────────
// static const loading = 'assets/gif/loading.gif';
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// 项目图标常量,统一维护,避免 Icons.xxx 散落在业务代码中。
///
/// 渲染逻辑(大小、颜色、点击态)由调用方负责,不在此处封装。
///
/// ## 使用
/// ```dart
/// Icon(AppIcons.send)
/// Icon(AppIcons.send, size: 20, color: Colors.white)
/// IconButton(icon: Icon(AppIcons.back), onPressed: ...)
/// ```
abstract final class AppIcons {
AppIcons._();
// ── 底部导航 ──────────────────────────────────────────────
static const chat = Icons.chat_bubble_outline_rounded;
static const contact = Icons.people_outline_rounded;
static const settings = Icons.settings_outlined;
// ── 通用操作 ──────────────────────────────────────────────
static const back = Icons.arrow_back_ios_new_rounded;
static const close = Icons.close_rounded;
static const more = Icons.more_horiz_rounded;
static const search = Icons.search_rounded;
static const add = Icons.add_rounded;
// ── 聊天输入区 ────────────────────────────────────────────
static const send = Icons.send_rounded;
static const attach = Icons.attach_file_rounded;
static const emoji = Icons.emoji_emotions_outlined;
static const camera = Icons.camera_alt_outlined;
static const voice = Icons.mic_outlined;
// ── 用户 / 联系人 ─────────────────────────────────────────
static const avatar = Icons.account_circle_outlined;
static const addUser = Icons.person_add_outlined;
// ── 状态反馈 ──────────────────────────────────────────────
static const success = Icons.check_circle_outline_rounded;
static const warning = Icons.warning_amber_rounded;
static const error = Icons.error_outline_rounded;
static const info = Icons.info_outline_rounded;
}

View File

@@ -21,9 +21,30 @@ import 'package:im_app/data/local/drift/tables/chats.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Favourites,Sounds,Tags,PendingFriendRequestHistories,Messages,RecentMiniApps,Retries,Groups,FavoriteMiniApps,DiscoverMiniApps,ChatCategories,ChatBots,FavouriteDetails,UserRequestHistories,Workspaces,Users,ExploreMiniApps,CallLogs,Chats]) //update mapping here
@DriftDatabase(
tables: [
Favourites,
Sounds,
Tags,
PendingFriendRequestHistories,
Messages,
RecentMiniApps,
Retries,
Groups,
FavoriteMiniApps,
DiscoverMiniApps,
ChatCategories,
ChatBots,
FavouriteDetails,
UserRequestHistories,
Workspaces,
Users,
ExploreMiniApps,
CallLogs,
Chats,
],
) //update mapping here
class AppDatabase extends _$AppDatabase {
static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) {
if (database is! AppDatabase) {
return {};
@@ -67,7 +88,9 @@ class AppDatabase extends _$AppDatabase {
// Create any new tables that don't exist yet
for (final table in allTables) {
final existingTables = await m.database
.customSelect("SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'")
.customSelect(
"SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'",
)
.get();
if (existingTables.isEmpty) {
@@ -92,6 +115,4 @@ class AppDatabase extends _$AppDatabase {
},
);
}
}

View File

@@ -21,4 +21,4 @@ class CallLogs extends Table {
@override
String get tableName => 'call_log';
}
}

View File

@@ -30,4 +30,4 @@ class ChatBots extends Table {
@override
String get tableName => 'chat_bot';
}
}

View File

@@ -17,4 +17,4 @@ class ChatCategories extends Table {
@override
String get tableName => 'chat_category';
}
}

View File

@@ -42,7 +42,8 @@ class Chats extends Table {
IntColumn get outgoingIdx => integer().withDefault(const Constant(0))();
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 notificationSoundId =>
integer().withDefault(const Constant(0))();
TextColumn get chatKey => text().withDefault(const Constant(''))();
TextColumn get activeChatKey => text().withDefault(const Constant(''))();
IntColumn get coverIdx => integer().withDefault(const Constant(0))();
@@ -55,4 +56,4 @@ class Chats extends Table {
@override
String get tableName => 'chat';
}
}

View File

@@ -33,4 +33,4 @@ class DiscoverMiniApps extends Table {
@override
String get tableName => 'discover_mini_app';
}
}

View File

@@ -33,4 +33,4 @@ class ExploreMiniApps extends Table {
@override
String get tableName => 'explore_mini_app';
}
}

View File

@@ -33,4 +33,4 @@ class FavoriteMiniApps extends Table {
@override
String get tableName => 'favorite_mini_app';
}
}

View File

@@ -13,4 +13,4 @@ class FavouriteDetails extends Table {
@override
String get tableName => 'favourite_detail';
}
}

View File

@@ -23,4 +23,4 @@ class Favourites extends Table {
@override
String get tableName => 'favourite';
}
}

View File

@@ -36,4 +36,4 @@ class Groups extends Table {
@override
String get tableName => 'chat_group';
}
}

View File

@@ -24,4 +24,4 @@ class Messages extends Table {
@override
String get tableName => 'message';
}
}

View File

@@ -14,4 +14,4 @@ class PendingFriendRequestHistories extends Table {
@override
String get tableName => 'pending_friend_request_histories';
}
}

View File

@@ -33,4 +33,4 @@ class RecentMiniApps extends Table {
@override
String get tableName => 'recent_mini_app';
}
}

View File

@@ -17,4 +17,4 @@ class Retries extends Table {
@override
String get tableName => 'retry';
}
}

View File

@@ -17,4 +17,4 @@ class Sounds extends Table {
@override
String get tableName => 'sound';
}
}

View File

@@ -12,4 +12,4 @@ class Tags extends Table {
@override
String get tableName => 'tags';
}
}

View File

@@ -11,4 +11,4 @@ class UserRequestHistories extends Table {
@override
String get tableName => 'user_request_history';
}
}

View File

@@ -28,9 +28,12 @@ class Users extends Table {
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))();
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()();
@@ -39,4 +42,4 @@ class Users extends Table {
@override
String get tableName => 'user';
}
}

View File

@@ -21,4 +21,4 @@ class Workspaces extends Table {
@override
String get tableName => 'workspace';
}
}

View File

@@ -119,4 +119,4 @@ class CallLogDto {
deletedAt: Value(deletedAt),
isRead: Value(isRead),
);
}
}

View File

@@ -176,4 +176,4 @@ class ChatBotDto {
isAllowForward: Value(isAllowForward),
tips: Value(tips),
);
}
}

View File

@@ -87,4 +87,4 @@ class ChatCategoryDto {
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
);
}
}

View File

@@ -197,4 +197,4 @@ class Chat {
localPermission: localPermission ?? this.localPermission,
);
}
}
}

View File

@@ -109,4 +109,4 @@ class DiscoverMiniApp {
screen: screen ?? this.screen,
);
}
}
}

View File

@@ -143,34 +143,33 @@ class ExploreMiniAppDto {
screen: screen,
);
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) =>
ExploreMiniAppDto(
id: app.id,
name: app.name,
openuid: app.openuid,
devId: app.devId,
icon: app.icon,
iconGaussian: app.iconGaussian,
downloadUrl: app.downloadUrl,
description: app.description,
version: app.version,
typ: app.typ,
flag: app.flag,
reviewStatus: app.reviewStatus,
favoriteAt: app.favoriteAt,
isActive: app.isActive,
createdAt: app.createdAt,
updatedAt: app.updatedAt,
deletedAt: app.deletedAt,
score: app.score,
channels: app.channels,
devName: app.devName,
pictureGaussian: app.pictureGaussian,
picture: app.picture,
commentNum: app.commentNum,
lastLoginAt: app.lastLoginAt,
screen: app.screen,
);
factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) => ExploreMiniAppDto(
id: app.id,
name: app.name,
openuid: app.openuid,
devId: app.devId,
icon: app.icon,
iconGaussian: app.iconGaussian,
downloadUrl: app.downloadUrl,
description: app.description,
version: app.version,
typ: app.typ,
flag: app.flag,
reviewStatus: app.reviewStatus,
favoriteAt: app.favoriteAt,
isActive: app.isActive,
createdAt: app.createdAt,
updatedAt: app.updatedAt,
deletedAt: app.deletedAt,
score: app.score,
channels: app.channels,
devName: app.devName,
pictureGaussian: app.pictureGaussian,
picture: app.picture,
commentNum: app.commentNum,
lastLoginAt: app.lastLoginAt,
screen: app.screen,
);
ExploreMiniAppsCompanion toCompanion() => ExploreMiniAppsCompanion(
id: Value(id),
@@ -199,4 +198,4 @@ class ExploreMiniAppDto {
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}
}

View File

@@ -199,4 +199,4 @@ class FavoriteMiniAppDto {
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}
}

View File

@@ -80,4 +80,4 @@ class FavouriteDetailDto {
chatId: Value(chatId),
sendTime: Value(sendTime),
);
}
}

View File

@@ -127,4 +127,4 @@ class FavouriteDto {
isUploaded: Value(isUploaded),
urls: Value(urls),
);
}
}

View File

@@ -218,4 +218,4 @@ class GroupDto {
topic: Value(topic),
rp: Value(rp),
);
}
}

View File

@@ -134,4 +134,4 @@ class MessageDto {
flag: Value(flag),
cmid: Value(cmid),
);
}
}

View File

@@ -31,33 +31,33 @@ class PendingFriendRequestHistoryDto {
);
Map<String, dynamic> toJson() => {
'id': id,
'uid': uid,
'request_time': requestTime,
'remarks': remarks,
'source': source,
'rs': rs,
};
'id': id,
'uid': uid,
'request_time': requestTime,
'remarks': remarks,
'source': source,
'rs': rs,
};
PendingFriendRequestHistory toEntity() => PendingFriendRequestHistory(
id: id,
uid: uid,
requestTime: requestTime,
remarks: remarks,
source: source,
rs: rs,
);
id: id,
uid: uid,
requestTime: requestTime,
remarks: remarks,
source: source,
rs: rs,
);
factory PendingFriendRequestHistoryDto.fromEntity(
PendingFriendRequestHistory history) =>
PendingFriendRequestHistoryDto(
id: history.id,
uid: history.uid,
requestTime: history.requestTime,
remarks: history.remarks,
source: history.source,
rs: history.rs,
);
PendingFriendRequestHistory history,
) => PendingFriendRequestHistoryDto(
id: history.id,
uid: history.uid,
requestTime: history.requestTime,
remarks: history.remarks,
source: history.source,
rs: history.rs,
);
PendingFriendRequestHistoriesCompanion toCompanion() =>
PendingFriendRequestHistoriesCompanion(
@@ -68,4 +68,4 @@ class PendingFriendRequestHistoryDto {
source: Value(source),
rs: Value(rs),
);
}
}

View File

@@ -198,4 +198,4 @@ class RecentMiniAppDto {
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}
}

View File

@@ -106,4 +106,4 @@ class RetryDto {
createTime: Value(createTime),
addIndex: Value(addIndex),
);
}
}

View File

@@ -85,4 +85,4 @@ class SoundDto {
channelGroupId: Value(channelGroupId),
isDefault: Value(isDefault),
);
}
}

View File

@@ -71,4 +71,4 @@ class TagDto {
updatedAt: Value(updatedAt),
addIndex: Value(addIndex),
);
}
}

View File

@@ -8,11 +8,7 @@ class UserRequestHistoryDto {
final int? status;
final int? createdAt;
const UserRequestHistoryDto({
required this.id,
this.status,
this.createdAt,
});
const UserRequestHistoryDto({required this.id, this.status, this.createdAt});
factory UserRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
UserRequestHistoryDto(
@@ -27,11 +23,8 @@ class UserRequestHistoryDto {
'created_at': createdAt,
};
UserRequestHistory toEntity() => UserRequestHistory(
id: id,
status: status,
createdAt: createdAt,
);
UserRequestHistory toEntity() =>
UserRequestHistory(id: id, status: status, createdAt: createdAt);
factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) =>
UserRequestHistoryDto(
@@ -40,10 +33,9 @@ class UserRequestHistoryDto {
createdAt: history.createdAt,
);
UserRequestHistoriesCompanion toCompanion() =>
UserRequestHistoriesCompanion(
id: Value(id),
status: Value(status),
createdAt: Value(createdAt),
);
}
UserRequestHistoriesCompanion toCompanion() => UserRequestHistoriesCompanion(
id: Value(id),
status: Value(status),
createdAt: Value(createdAt),
);
}

View File

@@ -113,4 +113,4 @@ class WorkspaceDto {
deletedAt: Value(deletedAt),
channelGroupId: Value(channelGroupId),
);
}
}

View File

@@ -6,28 +6,30 @@ import '../../../domain/entities/user.dart';
part 'get_profile_request.g.dart';
/// # /user/profile — 获取用户资料GET 请求示例
/// # /user/profile — 获取用户资料GET 请求)
///
/// 演示:GET 请求 + 无 body 参数的模式
/// GET 请求的 toJson() 结果会自动作为 URL query parameters 发送
/// GET 请求无 body`toJson()` 结果自动作为 URL query parameters 发送
/// 如需 query 参数(如分页),直接在类中添加字段,生成器自动序列化
///
/// ## 数据流位置
///
/// ```
/// UserRepositoryImpl.getProfile()
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里
/// → 服务端 GET /user/profile
/// → 响应 JSON → ★ ProfileData ★ ← 也在这里
/// → ProfileData.toEntity() → User
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
/// → ProfileResponse ★ = data 字段 ← 也在这里
/// → ProfileResponse.toEntity() → User
/// ```
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
/// 用户资料响应 DTO只需反序列化禁止生成无用的 toJson
@JsonSerializable(createToJson: false)
class ProfileData {
/// 用户资料接口的业务响应数据(对应服务端 `data` 字段)。
///
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理。纯 Dart 类,无需任何注解。
class ProfileResponse {
final int uid;
final String uuid;
@JsonKey(name: 'last_online')
@@ -54,7 +56,7 @@ class ProfileData {
final int channelGroupId;
final String hint;
const ProfileData({
const ProfileResponse({
required this.uid,
required this.uuid,
required this.lastOnline,
@@ -74,10 +76,6 @@ class ProfileData {
required this.hint,
});
factory ProfileData.fromJson(Map<String, dynamic> json) =>
_$ProfileDataFromJson(json);
/// DTO → Domain Entity
User toEntity() => User(
uid: uid,
uuid: uuid,
@@ -102,23 +100,12 @@ class ProfileData {
// ─────────────────────────────────────────────
/// 获取用户资料请求GET无参数
///
/// GET 请求无 bodytoJson() 返回空 map。
/// 如需 query 参数(如分页),添加字段即可,
/// toJson() 会自动将字段序列化为 URL query string。
@ApiRequest(
path: ApiPaths.userProfile,
method: HttpMethod.get,
responseType: ProfileData,
responseType: ProfileResponse,
)
@JsonSerializable()
class GetProfileRequest extends ApiRequestable<ProfileData>
class GetProfileRequest extends ApiRequestable<ProfileResponse>
with _$GetProfileRequestApi {
GetProfileRequest();
factory GetProfileRequest.fromJson(Map<String, dynamic> json) =>
_$GetProfileRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
}

View File

@@ -12,17 +12,20 @@ part 'login_request.g.dart';
///
/// ```
/// AuthRepositoryImpl.login(email, password)
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → 服务端 POST /auth/login
/// → 响应 JSON → ★ LoginResponse ★ ← 也在这里
/// → LoginResponse.toEntity() → User
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
/// → LoginResponse ★ = data 字段T in APIResponseWrapper<T> ← 也在这里
/// → LoginResponse.toEntity() → User
/// ```
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
@JsonSerializable(createToJson: false)
/// 登录响应中的用户档案,嵌套在 [LoginResponse.profile] 中。
///
/// 纯 Dart 类,无需任何注解。`_$LoginProfileFromJson` 由生成器从 `@ApiRequest` 声明中自动推导生成。
class LoginProfile {
final int uid;
final String uuid;
@@ -70,9 +73,6 @@ class LoginProfile {
required this.hint,
});
factory LoginProfile.fromJson(Map<String, dynamic> json) =>
_$LoginProfileFromJson(json);
User toEntity() => User(
uid: uid,
uuid: uuid,
@@ -92,8 +92,11 @@ class LoginProfile {
);
}
@JsonSerializable(createToJson: false, explicitToJson: true)
class LoginData {
/// 登录接口的业务响应数据(对应服务端 `data` 字段,即 T in `APIResponseWrapper<T>`)。
///
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理,
/// App 层只接触此类,不感知 envelope 结构。纯 Dart 类,无需任何注解。
class LoginResponse {
@JsonKey(name: 'account_id')
final String accountId;
final LoginProfile profile;
@@ -107,7 +110,7 @@ class LoginData {
@JsonKey(name: 'login_data')
final String loginData;
const LoginData({
const LoginResponse({
required this.accountId,
required this.profile,
required this.nonce,
@@ -117,52 +120,28 @@ class LoginData {
required this.loginData,
});
factory LoginData.fromJson(Map<String, dynamic> json) =>
_$LoginDataFromJson(json);
User toEntity() => profile.toEntity();
}
/// Top-level envelope: { "code": 0, "message": "OK", "data": { ... } }
@JsonSerializable(createToJson: false, explicitToJson: true)
class LoginResponse {
final int code;
final String message;
final LoginData data;
const LoginResponse({
required this.code,
required this.message,
required this.data,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) =>
_$LoginResponseFromJson(json);
User toEntity() => data.toEntity();
}
// ─────────────────────────────────────────────
// Request
// ─────────────────────────────────────────────
/// 登录请求
///
/// `@ApiRequest` 一个注解搞定一切:
/// - mixin 自动生成 path / method / requestType / includeToken / toJson
/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
@ApiRequest(
path: ApiPaths.authLogin,
method: HttpMethod.post,
responseType: LoginResponse,
requestType: ApiRequestType.login,
)
@JsonSerializable()
class LoginRequest extends ApiRequestable<LoginResponse>
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

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

View File

@@ -32,8 +32,7 @@ part 'upload_file_request.g.dart';
// Response DTO
// ─────────────────────────────────────────────
/// 文件上传响应 DTO只需反序列化禁止生成无用的 toJson
@JsonSerializable(createToJson: false)
/// 文件上传接口的业务响应数据(对应服务端 `data` 字段)。纯 Dart 类,无需任何注解。
class UploadResult {
final String url;
@@ -41,9 +40,6 @@ class UploadResult {
final String fileId;
const UploadResult({required this.url, required this.fileId});
factory UploadResult.fromJson(Map<String, dynamic> json) =>
_$UploadResultFromJson(json);
}
// ═════════════════════════════════════════════
@@ -52,7 +48,7 @@ class UploadResult {
/// FormData 上传请求
///
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 格式
/// 无需 override `decodeResponse`。
@ApiRequest(
path: ApiPaths.uploadFile,
@@ -97,7 +93,7 @@ class S3UploadResponse {
/// - path 为完整的 presigned URLSDK 检测到 http 开头不拼 baseURL
/// - uploadData 为 Uint8List 二进制数据
/// - 自定义 headersContent-Type: application/octet-stream
/// - override decodeResponse — S3 返回 204 No Content 或 XML不是标准信封
/// - override decodeResponse — S3 返回 204 No Content 或 XML不是标准响应格式
class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
final Uint8List data;
final String presignedURL;
@@ -115,8 +111,8 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
@override
Map<String, String>? get customHeaders => {
'Content-Type': 'application/octet-stream',
};
'Content-Type': 'application/octet-stream',
};
@override
Map<String, dynamic> toJson() => {};
@@ -125,7 +121,7 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
@override
Object? get uploadData => data;
/// S3 响应不走标准 { code, message, data } 信封,需要自定义解码
/// S3 响应不走标准 { code, message, data } 格式,需要自定义解码
///
/// 可能的响应:
/// - 204 No Content空 body→ 成功

View File

@@ -8,7 +8,7 @@ import '../remote/logout_request.dart';
/// 认证 Repository 实现
///
/// implements [AuthRepository] 接口domain/repositories/ 中定义)。
/// 直接使用 [ApiClient] 发送请求,将 DTO 转为 Domain Entity。
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
/// 后续可加 Local DataSource 实现离线缓存。
///
/// ## 数据流位置
@@ -16,18 +16,22 @@ import '../remote/logout_request.dart';
/// ```
/// LoginUseCase.execute(email, password)
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
/// → ApiClient.executeRequest(LoginRequest)
/// → NetworksSdkApi.executeRequest(LoginRequest)
/// → 服务端 POST /auth/login
/// ← LoginDataResponse DTO
/// → onTokenUpdate(token) ← 回调写入 Token
/// ← LoginData.toEntity() → User ← DTO → Entity 转换在这里
/// ← LoginResponseSDK 已拆包 { code, message, data } envelope
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
/// ← LoginResponse.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;
AuthRepositoryImpl({
required NetworksSdkApi client,
required void Function(String?) onTokenUpdate,
}) : _client = client,
_onTokenUpdate = onTokenUpdate;
@override
Future<User> login({required String email, required String password}) async {
@@ -39,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepository {
throw Exception('Login failed: empty response');
}
_onTokenUpdate(loginResponse.data.accessToken);
_onTokenUpdate(loginResponse.accessToken);
return loginResponse.toEntity();
}

View File

@@ -64,4 +64,4 @@ class CallLog {
isRead: isRead ?? this.isRead,
);
}
}
}

View File

@@ -197,4 +197,4 @@ class Chat {
localPermission: localPermission ?? this.localPermission,
);
}
}
}

View File

@@ -97,4 +97,4 @@ class ChatBot {
tips: tips ?? this.tips,
);
}
}
}

View File

@@ -45,4 +45,4 @@ class ChatCategory {
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
}

View File

@@ -109,4 +109,4 @@ class DiscoverMiniApp {
screen: screen ?? this.screen,
);
}
}
}

View File

@@ -109,4 +109,4 @@ class ExploreMiniApp {
screen: screen ?? this.screen,
);
}
}
}

View File

@@ -109,4 +109,4 @@ class FavoriteMiniApp {
screen: screen ?? this.screen,
);
}
}
}

View File

@@ -69,4 +69,4 @@ class Favourite {
urls: urls ?? this.urls,
);
}
}
}

View File

@@ -41,4 +41,4 @@ class FavouriteDetail {
sendTime: sendTime ?? this.sendTime,
);
}
}
}

View File

@@ -121,4 +121,4 @@ class Group {
rp: rp ?? this.rp,
);
}
}
}

View File

@@ -73,4 +73,4 @@ class Message {
cmid: cmid ?? this.cmid,
);
}
}
}

View File

@@ -33,4 +33,4 @@ class PendingFriendRequestHistory {
rs: rs ?? this.rs,
);
}
}
}

View File

@@ -109,4 +109,4 @@ class RecentMiniApp {
screen: screen ?? this.screen,
);
}
}
}

View File

@@ -57,4 +57,4 @@ class Retry {
addIndex: addIndex ?? this.addIndex,
);
}
}
}

View File

@@ -45,4 +45,4 @@ class Sound {
isDefault: isDefault ?? this.isDefault,
);
}
}
}

View File

@@ -37,4 +37,4 @@ class Tag {
addIndex: addIndex ?? this.addIndex,
);
}
}
}

View File

@@ -4,21 +4,13 @@ class UserRequestHistory {
final int? status;
final int? createdAt;
const UserRequestHistory({
required this.id,
this.status,
this.createdAt,
});
const UserRequestHistory({required this.id, this.status, this.createdAt});
UserRequestHistory copyWith({
int? id,
int? status,
int? createdAt,
}) {
UserRequestHistory copyWith({int? id, int? status, int? createdAt}) {
return UserRequestHistory(
id: id ?? this.id,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
);
}
}
}

View File

@@ -61,4 +61,4 @@ class Workspace {
channelGroupId: channelGroupId ?? this.channelGroupId,
);
}
}
}

View File

@@ -0,0 +1,39 @@
// 数据库测试页状态Demo正式开发后随页面一并删除
/// 单条测试结果记录
class TestResult {
final String title;
final String subtitle;
final String duration;
TestResult({
required this.title,
required this.subtitle,
required this.duration,
});
}
class ChatDbTestState {
final bool testStarted;
final List<TestResult> testResults;
final String currentState;
const ChatDbTestState({
this.testStarted = false,
this.testResults = const [],
this.currentState = '',
});
/// 按钮文案Widget 直接读,不在 View 层做判断)
String get buttonLabel => testStarted ? '结束' : '开始';
ChatDbTestState copyWith({
bool? testStarted,
List<TestResult>? testResults,
String? currentState,
}) => ChatDbTestState(
testStarted: testStarted ?? this.testStarted,
testResults: testResults ?? this.testResults,
currentState: currentState ?? this.currentState,
);
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart';
@@ -12,7 +13,7 @@ import '../../../../core/ui/base/context_theme_ext.dart';
///
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
class ChatDetailPage extends StatelessWidget {
class ChatDetailPage extends ConsumerWidget {
const ChatDetailPage({
super.key,
required this.conversationId,
@@ -23,7 +24,7 @@ class ChatDetailPage extends StatelessWidget {
final String title;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final s = context.styles;
return Scaffold(

View File

@@ -20,8 +20,6 @@ class ChatPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(chatViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('聊天')),
body: Center(
@@ -32,36 +30,48 @@ class ChatPage extends ConsumerWidget {
// 切换 Tab用 go替换整个历史栈不可返回
AppButton.inverse(
label: '切换 Tabgo',
onPressed: () => vm.goToContact(context),
onPressed: () =>
ref.read(chatViewModelProvider.notifier).goToContact(context),
),
// 带参数 pushextra 传 Dart Record适合已有对象的场景
AppButton.inverse(
label: '有参 pushextra',
onPressed: () => vm.pushChatDetailWithExtra(context),
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.pushChatDetailWithExtra(context),
),
// 带参数 pushid 内嵌在路径中,适合需要深链接 / 分享的场景
AppButton.inverse(
label: '有参 push路径参数',
onPressed: () => vm.pushChatDetailById(context),
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.pushChatDetailById(context),
),
// 无参 push压栈自动显示返回按钮不切 Tab
AppButton.inverse(
label: '无参 push',
onPressed: () => vm.pushSettingsTheme(context),
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.pushSettingsTheme(context),
),
// 无参 go替换历史切换到对应 TabTabBar 可见,不可返回
AppButton.inverse(
label: '无参 go',
onPressed: () => vm.goToSettings(context),
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.goToSettings(context),
),
AppButton.inverse(
label: '测试数据库性能',
onPressed: () => vm.goToDatabaseTest(context),
onPressed: () => ref
.read(chatViewModelProvider.notifier)
.goToDatabaseTest(context),
),
AppButton.secondary(
label: '退出登录',
fullWidth: false,
onPressed: () => vm.logout(),
onPressed: () =>
ref.read(chatViewModelProvider.notifier).logout(),
),
],
),

View File

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

View File

@@ -12,7 +12,7 @@ import '../usecases/login_usecase.dart';
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
///
/// Auth 模块的 DI 链路Repository → UseCase按需
/// app/di/ 只提供 SDK 基础设施apiConfig / apiClient / socketManager / storageApi
/// app/di/ 只提供 SDK 基础设施apiConfig / networkSdkApi / socketManager / storageApi
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
///
/// ```
@@ -21,7 +21,7 @@ import '../usecases/login_usecase.dart';
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
/// ```
@@ -41,7 +41,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
// TODO: final secureStorage = ref.read(secureStorageProvider);
return AuthRepositoryImpl(
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
onTokenUpdate: (token) {
apiConfig.updateToken(token); // 内存network_sdk
// TODO: secureStorage.saveToken(token); // 持久化crypto_sdk

View File

@@ -1,10 +1,10 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:im_app/app/di/user_provider.dart';
import 'package:im_app/data/remote/login_request.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/app/di/user_provider.dart';
import 'package:im_app/domain/entities/user.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:storage_sdk/storage_sdk.dart';
@@ -33,7 +33,7 @@ part 'login_view_model.g.dart';
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// ```
///
/// ## 数据流位置
@@ -44,7 +44,7 @@ part 'login_view_model.g.dart';
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
/// → AuthRepository.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginData → User
/// ← LoginResponse → User
/// ← User
/// → state = state.copyWith(user: user) ← 更新状态
/// View: ref.watch → 自动 rebuild ← UI 刷新
@@ -59,27 +59,52 @@ class LoginViewModel extends _$LoginViewModel {
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future<void> demoLogin() async {
// 防止连点重入:第一次调用未完成前忽略后续调用
if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null);
final storageApi = ref.read(storageSdkProvider);
final storageLifeCycle = storageApi as StorageSdkLifecycle;
final repositoryProvider = ref.read(userRepositoryProvider);
final provider = ref.read(authNotifierProvider);
// Read mock response from assets
final String raw = await rootBundle.loadString('assets/loginData.json');
final Map<String, dynamic> json = jsonDecode(raw);
try {
// 读取 mock 数据loginData.json 结构: { code, message, data: {...} }
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
final raw = await rootBundle.loadString('assets/loginData.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final profile = data['profile'] as Map<String, dynamic>;
// 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。
// Demo 场景直接从 JSON 字段构建 User不依赖生成的 fromJson。
final user = User(
uid: profile['uid'] as int,
uuid: profile['uuid'] as String,
lastOnline: profile['last_online'] as int,
profilePic: profile['profile_pic'] as String,
profilePicGaussian: profile['profile_pic_gaussian'] as String,
nickname: profile['nickname'] as String,
contact: profile['contact'] as String,
countryCode: profile['country_code'] as String,
email: profile['email'] as String,
recoveryEmail: profile['recovery_email'] as String,
username: profile['username'] as String,
bio: profile['bio'] as String,
relationship: profile['relationship'] as int,
userAlias: profile['user_alias'] as String?,
hint: profile['hint'] as String,
);
// Parse → Domain User directly
final loginResponse = LoginResponse.fromJson(json);
final user = loginResponse.data.toEntity();
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
await storageLifeCycle.openDatabase(user.uid);
// Save user to DB via repository
await repositoryProvider.saveUser(user);
// Open database for the user
await storageLifeCycle.openDatabase(user.uid);
// Save user to DB via repository
await repositoryProvider.saveUser(user);
// Trigger auth state
provider.login();
// Trigger auth state
provider.login();
} catch (e) {
// 导航已发生时 provider 已被 dispose静默丢弃不再写 state
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
/// 执行登录
@@ -89,12 +114,10 @@ class LoginViewModel extends _$LoginViewModel {
/// 3. 成功:写入 user失败写入 error
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
final provider = ref.read(loginUseCaseProvider);
try {
final user = await ref
.read(loginUseCaseProvider)
.execute(email: email, password: password);
final user = await provider.execute(email: email, password: password);
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) {
// 格式校验失败UseCase 层抛出)

View File

@@ -28,9 +28,9 @@ import '../../../domain/repositories/auth_repository.dart';
/// → AuthRepository.login()
/// → AuthRepositoryImpl.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginDataDTO
/// → _onTokenUpdate(token) ← 回调写入 Token内存 + 持久化,由 Provider 层组合)
/// ← LoginData.toEntity() → User
/// ← LoginResponseSDK 已拆包 envelope
/// → _onTokenUpdate(accessToken) ← 回调写入 Token内存 + 持久化,由 Provider 层组合)
/// ← LoginResponse.toEntity() → User
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
/// ← User
@@ -41,17 +41,18 @@ class LoginUseCase {
final ApiConfig _apiConfig;
final StorageSdkApi _storageApi;
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
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;
}) : _authRepository = authRepository,
_socketManager = socketManager,
_apiConfig = apiConfig,
_storageApi = storageApi;
/// 执行登录
///
@@ -72,10 +73,7 @@ class LoginUseCase {
_validatePassword(password);
// ── 2. 登录 ──
final user = await _authRepository.login(
email: email,
password: password,
);
final user = await _authRepository.login(email: email, password: password);
// ── 3. 连接 WebSocket ──
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig

View File

@@ -15,13 +15,12 @@ class LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch 保持 loginViewModelProvider 存活AutoDispose 需要至少一个监听者)
final state = ref.watch(loginViewModelProvider);
final s = context.styles;
return Scaffold(
appBar: AppBar(
title: const Text('登录'),
automaticallyImplyLeading: false,
),
appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -34,7 +33,9 @@ class LoginPage extends ConsumerWidget {
),
const SizedBox(height: 32),
FilledButton(
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
onPressed: state.isLoading
? null
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
child: const Text('登录'),
),
],

View File

@@ -16,32 +16,27 @@ class ThemeView extends ConsumerWidget {
final current = ref.watch(themeViewModelProvider);
return Scaffold(
appBar: AppBar(
title: const Text('主题'),
),
appBar: AppBar(title: const Text('主题')),
body: ListView(
children: [
const SettingsSectionHeader(title: '外观'),
ThemeOptionTile(
label: '跟随系统',
mode: ThemeMode.system,
current: current,
isSelected: current == ThemeMode.system,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.system),
),
ThemeOptionTile(
label: '黑色模式',
mode: ThemeMode.dark,
current: current,
isSelected: current == ThemeMode.dark,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.dark),
),
ThemeOptionTile(
label: '白色模式',
mode: ThemeMode.light,
current: current,
isSelected: current == ThemeMode.light,
onTap: () => ref
.read(themeViewModelProvider.notifier)
.setMode(ThemeMode.light),

View File

@@ -5,14 +5,13 @@ import '../../../../../core/ui/base/context_theme_ext.dart';
/// 单个主题选项行
///
/// 纯展示 + 事件透传,不感知任何 Provider。
/// 父级传入 [current] 判断选中状态[onTap] 处理切换。
/// 父级传入 [isSelected] 决定是否显示勾选图标[onTap] 处理切换。
///
/// 用法:
/// ```dart
/// ThemeOptionTile(
/// label: '黑色模式',
/// mode: ThemeMode.dark,
/// current: current,
/// isSelected: current == ThemeMode.dark,
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
/// )
/// ```
@@ -20,20 +19,17 @@ class ThemeOptionTile extends StatelessWidget {
const ThemeOptionTile({
super.key,
required this.label,
required this.mode,
required this.current,
required this.isSelected,
required this.onTap,
});
final String label;
final ThemeMode mode;
final ThemeMode current;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = context.styles;
final isSelected = current == mode;
return ListTile(
title: Text(label),