Merge branch 'dev' into happi/dev/database-update
# Conflicts: # apps/im_app/lib/features/login/presentation/login_view_model.dart
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
import 'package:im_app/core/ui/base/app_theme.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/app/router/app_router.dart';
|
||||
|
||||
/// 应用根组件
|
||||
///
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'package:im_app/app/app.dart';
|
||||
|
||||
void bootstrap() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/services/app_initializer.dart';
|
||||
import 'network_provider.dart';
|
||||
import 'package:im_app/core/foundation/device_info.dart';
|
||||
import 'package:im_app/core/services/app_initializer.dart';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
|
||||
// ── 认证 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,6 +123,11 @@ final appInitializerProvider = Provider<AppInitializer>((ref) {
|
||||
name: 'NetworkMonitor',
|
||||
task: () => ref.read(networkMonitorProvider).initialize(),
|
||||
),
|
||||
// 预取设备 ID / 设备名,platformHeaders 同步读取
|
||||
InitTask(
|
||||
name: 'DeviceInfo',
|
||||
task: DeviceInfo.init,
|
||||
),
|
||||
],
|
||||
deferred: [
|
||||
// TODO: 推送注册
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:storage_sdk/storage_sdk.dart';
|
||||
|
||||
import '../../data/local/drift/app_database.dart';
|
||||
import 'package:im_app/data/local/drift/app_database.dart';
|
||||
|
||||
/// 全局单例 StorageSdkApi,整个 App 生命周期内唯一实例。
|
||||
///
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
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/foundation/errors.dart';
|
||||
import '../../core/foundation/utils.dart';
|
||||
import '../../core/services/network_monitor.dart';
|
||||
import '../../core/services/socket_manager.dart';
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/core/foundation/config.dart';
|
||||
import 'package:im_app/core/foundation/constants.dart';
|
||||
import 'package:im_app/core/foundation/device_info.dart';
|
||||
import 'package:im_app/core/foundation/errors.dart';
|
||||
import 'package:im_app/core/foundation/utils.dart';
|
||||
import 'package:im_app/core/services/network_monitor.dart';
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
|
||||
// ── 网络状态监听 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,12 +37,7 @@ import '../../core/services/socket_manager.dart';
|
||||
/// });
|
||||
/// ```
|
||||
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
|
||||
final monitor = NetworkMonitor(
|
||||
onLog: (message, {tag}) {
|
||||
// ignore: avoid_print
|
||||
print('[${tag ?? 'Network'}] $message');
|
||||
},
|
||||
);
|
||||
final monitor = NetworkMonitor(onLog: _makeLogger('Network'));
|
||||
|
||||
ref.onDispose(() {
|
||||
monitor.dispose();
|
||||
@@ -80,15 +77,13 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
return ApiConfig(
|
||||
baseURL: AppConfig.apiBaseUrl,
|
||||
platformHeaders: {
|
||||
'Platform': 'Android', // TODO: 运行时从 platform API 获取
|
||||
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
|
||||
'Channel': '', // TODO: 从 AppConfig 读取渠道标识
|
||||
'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
|
||||
},
|
||||
tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
|
||||
forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
|
||||
onForceLogout: () {
|
||||
// TODO: 清除登录态,跳转登录页
|
||||
'platform': DeviceInfo.platform,
|
||||
'os-type': DeviceInfo.osType.toString(),
|
||||
'client-version': AppConfig.appVersion,
|
||||
'channel': AppConfig.channel,
|
||||
'lang': DeviceInfo.lang,
|
||||
'device-id': DeviceInfo.deviceId,
|
||||
'device-name': DeviceInfo.deviceName,
|
||||
},
|
||||
onTokenRefresh: () async {
|
||||
// TODO: App 层刷新 token 逻辑
|
||||
@@ -98,7 +93,7 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
// 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖
|
||||
tokenStream.add(newToken);
|
||||
},
|
||||
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
|
||||
onCheckNetworkAvailable: _checkNetwork(networkMonitor),
|
||||
// TODO: 接入 cipher_guard_sdk 后注入请求加密回调。
|
||||
// 前提:AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。
|
||||
// 示例:
|
||||
@@ -117,16 +112,43 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
// return jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
// },
|
||||
onDecryptResponse: null,
|
||||
onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等)
|
||||
onBusinessError: (code, message, path) {
|
||||
switch (code) {
|
||||
// Token 过期:SDK 自动刷 token + 重试,业务层无感
|
||||
case ApiErrorCodes.tokenInvalid:
|
||||
case ApiErrorCodes.jwtInvalid:
|
||||
case ApiErrorCodes.sessionInvalid:
|
||||
return BusinessErrorAction.refreshToken;
|
||||
|
||||
// Token 刷新失败 / refresh token 失效:强制登出
|
||||
case ApiErrorCodes.refreshTokenFailed:
|
||||
// TODO: 清除登录态,跳转登录页
|
||||
return BusinessErrorAction.forceLogout;
|
||||
|
||||
// 踢下线:账号在其他设备登录、签名/密钥异常
|
||||
case ApiErrorCodes.loggedInAnotherDevice:
|
||||
case ApiErrorCodes.signingMethodError:
|
||||
case ApiErrorCodes.parsingKeyError:
|
||||
// TODO: 接入全局 Toast/弹窗机制后展示踢下线提示,并跳转登录页
|
||||
return BusinessErrorAction.handled;
|
||||
|
||||
// 触发图片验证:需展示 CAPTCHA 后重发 OTP
|
||||
// data 中含 android / ios / web 平台 token(见 SendOtpCaptchaData)
|
||||
case ApiErrorCodes.captchaRequired:
|
||||
// TODO: 接入 CAPTCHA SDK,验证通过后重发 OTP
|
||||
return BusinessErrorAction.handled;
|
||||
|
||||
default:
|
||||
// 单接口自行处理(ViewModel 的 guard 会收到 ApiError)
|
||||
return BusinessErrorAction.unhandled;
|
||||
}
|
||||
},
|
||||
onTransformResponse:
|
||||
null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
|
||||
onGetTokenExpiry: parseJwtExpiry,
|
||||
maxRetries: AppConstants.maxRetries,
|
||||
retryBaseDelay: AppConstants.retryBaseDelay,
|
||||
onLog: (message, {tag}) {
|
||||
// ignore: avoid_print
|
||||
print('[${tag ?? 'Network'}] $message');
|
||||
},
|
||||
onLog: _makeLogger('Network'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -146,48 +168,30 @@ final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
|
||||
/// SDK 内部不调用其他 SDK。
|
||||
final _socketConfigProvider = Provider<SocketConfig>((ref) {
|
||||
final networkMonitor = ref.read(networkMonitorProvider);
|
||||
final apiConfig = ref.read(apiConfigProvider);
|
||||
|
||||
return SocketConfig(
|
||||
maxReconnectAttempts: AppConstants.maxRetries,
|
||||
maxReconnectDelay: AppConstants.maxReconnectDelay,
|
||||
unlimitedReconnect: true, // IM 场景始终保持连接
|
||||
onBuildConnectUrl:
|
||||
null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数)
|
||||
// 接入 cipher_guard_sdk 后改为 cipher=true&type=mode3
|
||||
onBuildConnectUrl: (url, token) {
|
||||
final uri = Uri.parse(url);
|
||||
final params = <String, String>{
|
||||
...uri.queryParameters,
|
||||
if (token != null) 'token': token, // ignore: use_null_aware_elements
|
||||
'cipher': 'true',
|
||||
'type': 'mode2',
|
||||
};
|
||||
return uri.replace(queryParameters: params).toString();
|
||||
},
|
||||
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');
|
||||
},
|
||||
onCheckNetworkAvailable: () async {
|
||||
return networkMonitor.isConnected;
|
||||
},
|
||||
// SocketClient 内部重连(心跳超时 / stream onDone)前调用
|
||||
onBeforeReconnect: () =>
|
||||
_proactiveTokenRefresh(apiConfig, logTag: 'Socket'),
|
||||
onLog: _makeLogger('Socket'),
|
||||
onCheckNetworkAvailable: _checkNetwork(networkMonitor),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -232,32 +236,11 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
|
||||
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
|
||||
print('[${tag ?? 'SocketManager'}] $message');
|
||||
},
|
||||
// SocketManager 层重连(前台恢复 / 网络恢复)前调用
|
||||
onBeforeReconnect: () =>
|
||||
_proactiveTokenRefresh(apiConfig, logTag: 'SocketManager'),
|
||||
onCheckNetworkAvailable: _checkNetwork(networkMonitor),
|
||||
onLog: _makeLogger('SocketManager'),
|
||||
);
|
||||
|
||||
// 监听 token 更新事件 → 同步到 WebSocket
|
||||
@@ -281,6 +264,47 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 日志回调工厂,各模块传自己的默认 tag
|
||||
///
|
||||
/// SDK 内部调用 onLog 时通常已传 tag,defaultTag 仅作兜底。
|
||||
OnLog _makeLogger(String defaultTag) => (message, {tag}) {
|
||||
debugPrint('[${tag ?? defaultTag}] $message');
|
||||
};
|
||||
|
||||
/// 网络可用性检查回调,HTTP 和 WebSocket 共用
|
||||
OnCheckNetworkAvailable _checkNetwork(NetworkMonitor monitor) =>
|
||||
() async => monitor.isConnected;
|
||||
|
||||
/// 重连前主动刷新 token:距过期不足阈值时提前刷新
|
||||
///
|
||||
/// 两处调用:
|
||||
/// - SocketClient 内部重连(心跳超时 / stream onDone)前
|
||||
/// - SocketManager 重连(前台恢复 / 网络恢复)前
|
||||
///
|
||||
/// 刷新后通过 onTokenUpdated → sync stream → socketClient._currentToken 同步更新,
|
||||
/// 确保随后的 _doConnect() 使用新 token。
|
||||
Future<void> _proactiveTokenRefresh(
|
||||
ApiConfig config, {
|
||||
required String logTag,
|
||||
}) async {
|
||||
final currentToken = config.token;
|
||||
if (currentToken == null || config.onGetTokenExpiry == null) return;
|
||||
|
||||
final expiry = config.onGetTokenExpiry!(currentToken);
|
||||
if (expiry == null) return;
|
||||
|
||||
final remaining = expiry.difference(DateTime.now());
|
||||
if (remaining > config.proactiveRefreshThreshold) return;
|
||||
|
||||
debugPrint(
|
||||
'[$logTag] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
|
||||
);
|
||||
final newToken = await config.onTokenRefresh?.call();
|
||||
if (newToken != null && newToken.isNotEmpty) {
|
||||
config.updateToken(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP baseURL → WebSocket URL 转换
|
||||
///
|
||||
/// https://api.example.com → wss://api.example.com/ws
|
||||
@@ -392,9 +416,9 @@ String _buildWsUrl(String httpBaseUrl) {
|
||||
// 「一个接口 = 一个 Request 文件」,严格按层调用,禁止跳层。
|
||||
//
|
||||
// ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
// │ 文件 & 职责总览 │
|
||||
// │ 文件 & 职责总览 │
|
||||
// ├──────────────────────────────────────────────────────────────────────────┤
|
||||
// │ login_request.dart Request + Response DTO(一个端点一个文件) │
|
||||
// │ login_request.dart Request + Response DTO(一个端点一个文件) │
|
||||
// │ auth_repository_impl.dart executeRequest → DTO → Entity + 回调写 Token│
|
||||
// │ login_usecase.dart 格式校验 → 调 Repository(按需,非必须) │
|
||||
// │ auth_providers.dart DI 装配(Repository → UseCase 按需) │
|
||||
|
||||
@@ -3,16 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:im_app/features/chat/view/chat_db_test_page.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';
|
||||
import 'package:im_app/features/app_tab/view/app_tab.dart';
|
||||
import 'package:im_app/features/chat/view/chat_detail_page.dart';
|
||||
import 'package:im_app/features/chat/view/chat_page.dart';
|
||||
import 'package:im_app/features/contact/view/contact_page.dart';
|
||||
import 'package:im_app/features/login/view/login_page.dart';
|
||||
import 'package:im_app/features/settings/view/settings_page.dart';
|
||||
import 'package:im_app/features/settings/view/theme_view.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
import 'package:im_app/app/router/guards/auth_guard.dart';
|
||||
|
||||
/// 应用路由 Provider
|
||||
///
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../di/app_providers.dart';
|
||||
import '../app_route_name.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
/// 登录守卫
|
||||
///
|
||||
|
||||
@@ -11,21 +11,23 @@ class ApiPaths {
|
||||
ApiPaths._();
|
||||
|
||||
// ── Auth ──
|
||||
static const authLogin = '/auth/login';
|
||||
static const authRefreshToken = '/auth/refresh-token';
|
||||
static const authLogout = '/auth/logout';
|
||||
static const authSendOtp = '/app/api/auth/vcode/get';
|
||||
static const authVerifyOtp = '/app/api/auth/vcode/check';
|
||||
static const authLogin = '/app/api/auth/login-user';
|
||||
static const authRefreshToken = '/app/api/auth/refresh-token';
|
||||
static const authLogout = '/app/api/auth/logout';
|
||||
|
||||
// ── User ──
|
||||
static const userProfile = '/user/profile';
|
||||
static const userUpdateProfile = '/user/update-profile';
|
||||
static const userProfile = '/app/api/user/profile';
|
||||
static const userUpdateProfile = '/app/api/user/update-profile';
|
||||
|
||||
// ── Chat ──
|
||||
static const chatSendMessage = '/chat/send-message';
|
||||
static const chatHistory = '/chat/history';
|
||||
static const chatSendMessage = '/app/api/chat/send-message';
|
||||
static const chatHistory = '/app/api/chat/history';
|
||||
|
||||
// ── Upload ──
|
||||
static const uploadFile = '/upload/file';
|
||||
static const uploadFile = '/app/api/upload/file';
|
||||
|
||||
// ── WebSocket ──
|
||||
static const wsConnect = '/ws';
|
||||
static const wsConnect = '/websock/open';
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ class AppConfig {
|
||||
static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true);
|
||||
static const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://dev-api.example.com',
|
||||
defaultValue: 'http://gateway.winwayinfo.com',
|
||||
);
|
||||
|
||||
static const channel = String.fromEnvironment('CHANNEL', defaultValue: '10');
|
||||
|
||||
static const appVersion = String.fromEnvironment(
|
||||
'APP_VERSION',
|
||||
defaultValue: '1.0.0',
|
||||
);
|
||||
|
||||
static bool get isProd => !isDev;
|
||||
|
||||
108
apps/im_app/lib/core/foundation/device_info.dart
Normal file
108
apps/im_app/lib/core/foundation/device_info.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
/// 设备 / 运行时信息 — 用于构建 HTTP 请求头中的平台字段
|
||||
///
|
||||
/// 同步 getter(platform / lang / osType)可直接使用。
|
||||
/// deviceId / deviceName 需调用 [init()] 预取后才能通过同步 getter 访问。
|
||||
///
|
||||
/// 在 `AppInitializer.critical` 中调用 [init()],之后 `platformHeaders` 可同步读取所有字段:
|
||||
/// ```dart
|
||||
/// platformHeaders: {
|
||||
/// 'platform': DeviceInfo.platform,
|
||||
/// 'lang': DeviceInfo.lang,
|
||||
/// 'client-version': AppConfig.appVersion,
|
||||
/// 'channel': AppConfig.channel,
|
||||
/// 'device-id': DeviceInfo.deviceId,
|
||||
/// 'device-name': DeviceInfo.deviceName,
|
||||
/// }
|
||||
/// ```
|
||||
// ignore: avoid_classes_with_only_static_members
|
||||
class DeviceInfo {
|
||||
DeviceInfo._();
|
||||
|
||||
static String _deviceId = '';
|
||||
static String _deviceName = '';
|
||||
|
||||
/// 预取设备 ID / 设备名,缓存后可通过同步 getter 访问。
|
||||
/// 在 AppInitializer.critical 中调用一次即可。
|
||||
static Future<void> init() async {
|
||||
_deviceId = await fetchDeviceId();
|
||||
_deviceName = await fetchDeviceName();
|
||||
}
|
||||
|
||||
/// 缓存的设备唯一标识(需先调用 [init()])
|
||||
static String get deviceId => _deviceId;
|
||||
|
||||
/// 缓存的设备名称(需先调用 [init()])
|
||||
static String get deviceName => _deviceName;
|
||||
|
||||
/// HTTP `Platform` 请求头(服务端用于区分来源平台)
|
||||
///
|
||||
/// 返回值:iOS / Android / macOS / Windows / Linux
|
||||
static String get platform {
|
||||
if (Platform.isIOS) return 'iOS';
|
||||
if (Platform.isAndroid) return 'Android';
|
||||
if (Platform.isMacOS) return 'macOS';
|
||||
if (Platform.isWindows) return 'Windows';
|
||||
if (Platform.isLinux) return 'Linux';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// HTTP `lang` 请求头(取系统首选语言,如 "zh-CN"、"en-US")
|
||||
///
|
||||
/// 使用 `dart:ui` 的 `PlatformDispatcher`,不依赖 Flutter widget tree。
|
||||
static String get lang => PlatformDispatcher.instance.locale.toLanguageTag();
|
||||
|
||||
/// 操作系统类型编号(与服务端约定一致):
|
||||
/// 1=Android 2=iOS 3=Windows 4=macOS 5=Linux 6=其他
|
||||
static int get osType {
|
||||
if (Platform.isAndroid) return 1;
|
||||
if (Platform.isIOS) return 2;
|
||||
if (Platform.isWindows) return 3;
|
||||
if (Platform.isMacOS) return 4;
|
||||
if (Platform.isLinux) return 5;
|
||||
return 6;
|
||||
}
|
||||
|
||||
/// 设备唯一标识
|
||||
///
|
||||
/// Android:Build.ID(非持久化硬件 ID,可作为临时标识;如需稳定 ID 后续接入 android_id)
|
||||
/// iOS:identifierForVendor
|
||||
/// macOS:systemGUID
|
||||
/// Windows:deviceId
|
||||
static Future<String> fetchDeviceId() async {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
return (await plugin.androidInfo).id;
|
||||
} else if (Platform.isIOS) {
|
||||
return (await plugin.iosInfo).identifierForVendor ?? '';
|
||||
} else if (Platform.isMacOS) {
|
||||
return (await plugin.macOsInfo).systemGUID ?? '';
|
||||
} else if (Platform.isWindows) {
|
||||
return (await plugin.windowsInfo).deviceId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 设备名称(品牌 + 型号)
|
||||
static Future<String> fetchDeviceName() async {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final info = await plugin.androidInfo;
|
||||
return '${info.brand} ${info.model}';
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
return info.utsname.machine;
|
||||
} else if (Platform.isMacOS) {
|
||||
return (await plugin.macOsInfo).model;
|
||||
} else if (Platform.isWindows) {
|
||||
return (await plugin.windowsInfo).computerName;
|
||||
} else if (Platform.isLinux) {
|
||||
return (await plugin.linuxInfo).name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -36,22 +36,9 @@ class ApiErrorCodes {
|
||||
/// 账号在其他设备登录
|
||||
static const int loggedInAnotherDevice = 30006;
|
||||
|
||||
// ── 错误码集合 ──
|
||||
// ── 验证码(30170-30179)──
|
||||
|
||||
/// Token 过期错误码集合 — 触发自动刷新 Token
|
||||
static const Set<int> tokenExpiredCodes = {
|
||||
tokenInvalid,
|
||||
jwtInvalid,
|
||||
sessionInvalid,
|
||||
};
|
||||
/// 触发图片验证:data 含各平台 CAPTCHA token(android / ios / web)
|
||||
static const int captchaRequired = 30174;
|
||||
|
||||
/// 强制登出错误码集合 — 触发退出登录流程
|
||||
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
|
||||
|
||||
/// 踢下线错误码集合 — 触发踢下线 UI 提示
|
||||
static const Set<int> kickOffCodes = {
|
||||
loggedInAnotherDevice,
|
||||
signingMethodError,
|
||||
parsingKeyError,
|
||||
};
|
||||
}
|
||||
|
||||
54
apps/im_app/lib/core/presentation/request_guard.dart
Normal file
54
apps/im_app/lib/core/presentation/request_guard.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
/// ViewModel 请求守卫 mixin
|
||||
///
|
||||
/// 统一拦截 API 调用的 [ApiError],无需在每个 ViewModel 方法内写 try-catch。
|
||||
/// [ApiError.displayMessage] 自动映射为可读文案,直接写入 state。
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// @riverpod
|
||||
/// class LoginViewModel extends _$LoginViewModel with RequestGuard<LoginState> {
|
||||
///
|
||||
/// Future<void> sendOtp(String phone) async {
|
||||
/// state = state.copyWith(isLoading: true, error: null);
|
||||
///
|
||||
/// final ok = await guard(
|
||||
/// () => ref.read(loginUseCaseProvider).sendOtp(phone),
|
||||
/// onError: (msg) => state = state.copyWith(isLoading: false, error: msg),
|
||||
/// );
|
||||
///
|
||||
/// if (ok != null) {
|
||||
/// state = state.copyWith(isLoading: false, step: LoginStep.otpSent);
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## 机制
|
||||
///
|
||||
/// ```
|
||||
/// guard()
|
||||
/// └─ try: call() → 成功,返回 T
|
||||
/// └─ on ApiError: onError(e.displayMessage) → 返回 null
|
||||
///
|
||||
/// ViewModel: ok != null → 成功路径
|
||||
/// ok == null → 已由 onError 更新 state.error,无需额外处理
|
||||
/// ```
|
||||
mixin RequestGuard<S> on Notifier<S> {
|
||||
/// 执行 [call],捕获 [ApiError] 后调用 [onError] 写入错误文案,返回 null。
|
||||
/// 成功时返回原始结果,ViewModel 用返回值是否为 null 判断走哪条路径。
|
||||
Future<T?> guard<T>(
|
||||
Future<T> Function() call, {
|
||||
required void Function(String message) onError,
|
||||
}) async {
|
||||
try {
|
||||
return await call();
|
||||
} on ApiError catch (e) {
|
||||
onError(e.displayMessage);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'network_backoff_debouncer.dart';
|
||||
import 'package:im_app/core/services/network_backoff_debouncer.dart';
|
||||
|
||||
/// 消息预处理回调
|
||||
///
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
import 'font.dart';
|
||||
import 'package:im_app/core/ui/base/colors.dart';
|
||||
import 'package:im_app/core/ui/base/font.dart';
|
||||
|
||||
/// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData
|
||||
///
|
||||
|
||||
7
apps/im_app/lib/core/ui/base/app_theme_ext.dart
Normal file
7
apps/im_app/lib/core/ui/base/app_theme_ext.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:im_app/core/ui/base/shadows.dart';
|
||||
|
||||
|
||||
extension AppThemeExt on BuildContext {
|
||||
AppShadows get shadows => AppShadows(this);
|
||||
}
|
||||
@@ -37,4 +37,8 @@ class AppColors {
|
||||
static const gray800 = Color(0xFF3C4043);
|
||||
static const gray900 = Color(0xFF202124);
|
||||
static const black = Color(0xFF000000);
|
||||
|
||||
// ── Neutral black Scale ─────────────────────────────────────────────────────
|
||||
static const black12 = Color(0x1F000000); // 12% opacity
|
||||
static const black60 = Color(0x99000000); // 60% opacity
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'font.dart';
|
||||
import 'package:im_app/core/ui/base/font.dart';
|
||||
|
||||
/// 主题样式快捷封装
|
||||
///
|
||||
|
||||
131
apps/im_app/lib/core/ui/base/radius.dart
Normal file
131
apps/im_app/lib/core/ui/base/radius.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 圆角设计 Token
|
||||
///
|
||||
/// 统一管理项目中的圆角规范,避免在业务代码中直接写
|
||||
/// `Radius.circular()` 或 `BorderRadius.circular()`
|
||||
///
|
||||
/// 使用方式:
|
||||
///
|
||||
/// ```dart
|
||||
/// Container(
|
||||
/// decoration: BoxDecoration(
|
||||
/// borderRadius: AppRadius.card,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// 设计规范来源:
|
||||
/// 通常来自 UI 设计系统,例如
|
||||
/// 4 / 8 / 12 / 16 / 20
|
||||
class AppRadius {
|
||||
/// 私有构造函数,防止被实例化
|
||||
AppRadius._();
|
||||
|
||||
// ================================
|
||||
// 基础 Radius Token
|
||||
// ================================
|
||||
// 用于组合 BorderRadius
|
||||
|
||||
/// 4px 圆角
|
||||
static const Radius r4 = Radius.circular(4);
|
||||
|
||||
/// 6px 圆角
|
||||
static const Radius r6 = Radius.circular(6);
|
||||
|
||||
/// 8px 圆角(常用于按钮)
|
||||
static const Radius r8 = Radius.circular(8);
|
||||
|
||||
/// 10px 圆角
|
||||
static const Radius r10 = Radius.circular(10);
|
||||
|
||||
/// 12px 圆角(常用于卡片)
|
||||
static const Radius r12 = Radius.circular(12);
|
||||
|
||||
/// 14px 圆角
|
||||
static const Radius r14 = Radius.circular(14);
|
||||
|
||||
/// 16px 圆角(常用于弹窗)
|
||||
static const Radius r16 = Radius.circular(16);
|
||||
|
||||
/// 18px 圆角
|
||||
static const Radius r18 = Radius.circular(18);
|
||||
|
||||
/// 20px 圆角
|
||||
static const Radius r20 = Radius.circular(20);
|
||||
|
||||
// ================================
|
||||
// 组件级设计 Token
|
||||
// ================================
|
||||
// 推荐优先使用这些,而不是直接使用 brXX
|
||||
|
||||
/// 卡片圆角
|
||||
///
|
||||
/// 示例:Card / 商品卡片 / 信息卡片
|
||||
static const BorderRadius card = BorderRadius.all(r12);
|
||||
|
||||
/// 按钮圆角
|
||||
///
|
||||
/// 示例:PrimaryButton / SecondaryButton
|
||||
static const BorderRadius button = BorderRadius.all(r8);
|
||||
|
||||
/// 弹窗圆角
|
||||
///
|
||||
/// 示例:Dialog / Modal
|
||||
static const BorderRadius dialog = BorderRadius.all(r16);
|
||||
|
||||
// ================================
|
||||
// 通用 BorderRadius
|
||||
// ================================
|
||||
// 当组件 Token 不满足需求时使用
|
||||
|
||||
static const BorderRadius br4 = BorderRadius.all(r4);
|
||||
|
||||
static const BorderRadius br6 = BorderRadius.all(r6);
|
||||
|
||||
static const BorderRadius br8 = BorderRadius.all(r8);
|
||||
|
||||
static const BorderRadius br10 = BorderRadius.all(r10);
|
||||
|
||||
static const BorderRadius br12 = BorderRadius.all(r12);
|
||||
|
||||
static const BorderRadius br14 = BorderRadius.all(r14);
|
||||
|
||||
static const BorderRadius br16 = BorderRadius.all(r16);
|
||||
|
||||
static const BorderRadius br18 = BorderRadius.all(r18);
|
||||
|
||||
static const BorderRadius br20 = BorderRadius.all(r20);
|
||||
|
||||
// ================================
|
||||
// 辅助方法
|
||||
// ================================
|
||||
// 用于生成顶部或底部圆角
|
||||
|
||||
/// 生成顶部圆角
|
||||
///
|
||||
/// 常用于:
|
||||
/// - BottomSheet
|
||||
/// - 底部弹窗
|
||||
/// - 半屏弹层
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// borderRadius: AppRadius.top(AppRadius.r16)
|
||||
/// ```
|
||||
static BorderRadius top(Radius r) =>
|
||||
BorderRadius.vertical(top: r);
|
||||
|
||||
/// 生成底部圆角
|
||||
///
|
||||
/// 常用于:
|
||||
/// - Header
|
||||
/// - 顶部卡片
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// borderRadius: AppRadius.bottom(AppRadius.r16)
|
||||
/// ```
|
||||
static BorderRadius bottom(Radius r) =>
|
||||
BorderRadius.vertical(bottom: r);
|
||||
}
|
||||
129
apps/im_app/lib/core/ui/base/shadows.dart
Normal file
129
apps/im_app/lib/core/ui/base/shadows.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
|
||||
/// 阴影 Design Token
|
||||
///
|
||||
/// 统一管理项目中的阴影规范,避免在业务代码中直接书写 `BoxShadow`。
|
||||
/// 所有阴影通过 Design Token 提供,保证:
|
||||
///
|
||||
/// - UI 风格统一
|
||||
/// - 支持 Dark / Light Mode
|
||||
/// - 与设计稿(Figma)保持一致
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AppColors(颜色常量)
|
||||
/// → AppShadows(阴影 Token)
|
||||
/// → Context Extension(context.shadows)
|
||||
/// → View 层消费
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用示例
|
||||
///
|
||||
/// ```dart
|
||||
/// Container(
|
||||
/// decoration: BoxDecoration(
|
||||
/// color: Colors.white,
|
||||
/// boxShadow: context.shadows.bs8,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// ## Elevation 体系
|
||||
///
|
||||
/// 阴影遵循常见 UI 设计系统的层级规范:
|
||||
///
|
||||
/// - **4** : 小卡片 / List Item
|
||||
/// - **8** : Card / 商品卡片
|
||||
/// - **12** : Dropdown / Popover
|
||||
/// - **16** : Dialog / Modal / 悬浮面板
|
||||
class AppShadows {
|
||||
/// 构造函数,通过 BuildContext 获取当前主题
|
||||
AppShadows(this.context);
|
||||
|
||||
/// 当前 Widget 的 BuildContext
|
||||
///
|
||||
/// 用于根据 Theme 判断 Light / Dark Mode,
|
||||
/// 从而动态获取阴影颜色。
|
||||
final BuildContext context;
|
||||
|
||||
/// 内部统一阴影生成方法
|
||||
///
|
||||
/// 避免重复创建 `BoxShadow` 逻辑,
|
||||
/// 所有阴影 Token 都通过该方法生成。
|
||||
List<BoxShadow> _shadow({
|
||||
required double blur,
|
||||
required double dy,
|
||||
}) {
|
||||
return [
|
||||
BoxShadow(
|
||||
|
||||
/// 阴影颜色来自 Design Token
|
||||
color: _shadowColor,
|
||||
|
||||
/// 模糊半径(影响阴影扩散范围)
|
||||
blurRadius: blur,
|
||||
|
||||
/// 阴影偏移
|
||||
offset: Offset(0, dy),
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/// Elevation 4
|
||||
///
|
||||
/// 适用场景:
|
||||
/// - List Item
|
||||
/// - 小卡片
|
||||
List<BoxShadow> get bs4 =>
|
||||
_shadow(
|
||||
blur: 4,
|
||||
dy: 2,
|
||||
);
|
||||
|
||||
/// Elevation 8
|
||||
///
|
||||
/// 适用场景:
|
||||
/// - Card
|
||||
/// - 商品卡片
|
||||
List<BoxShadow> get bs8 =>
|
||||
_shadow(
|
||||
blur: 8,
|
||||
dy: 4,
|
||||
);
|
||||
|
||||
/// Elevation 12
|
||||
///
|
||||
/// 适用场景:
|
||||
/// - Dropdown
|
||||
/// - Popover
|
||||
List<BoxShadow> get bs12 =>
|
||||
_shadow(
|
||||
blur: 12,
|
||||
dy: 8,
|
||||
);
|
||||
|
||||
/// Elevation 16
|
||||
///
|
||||
/// 适用场景:
|
||||
/// - Dialog
|
||||
/// - Modal
|
||||
/// - Floating Panel
|
||||
List<BoxShadow> get bs16 =>
|
||||
_shadow(
|
||||
blur: 16,
|
||||
dy: 8,
|
||||
);
|
||||
|
||||
/// 阴影颜色 Token
|
||||
Color get _shadowColor {
|
||||
final brightness = Theme
|
||||
.of(context)
|
||||
.brightness;
|
||||
|
||||
return brightness == Brightness.dark
|
||||
? AppColors.black60
|
||||
: AppColors.black12;
|
||||
}
|
||||
}
|
||||
72
apps/im_app/lib/core/ui/base/spacing.dart
Normal file
72
apps/im_app/lib/core/ui/base/spacing.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
/// 间距设计 Token
|
||||
///
|
||||
/// 统一管理项目中的间距规范,避免在业务代码中直接写 magic number,例如:
|
||||
///
|
||||
/// ❌ 不推荐
|
||||
/// ```dart
|
||||
/// Padding(padding: EdgeInsets.all(16))
|
||||
/// ```
|
||||
///
|
||||
/// ✅ 推荐
|
||||
/// ```dart
|
||||
/// Padding(padding: EdgeInsets.all(AppSpacing.s16))
|
||||
/// ```
|
||||
///
|
||||
/// 常用于:
|
||||
/// - Padding
|
||||
/// - Margin
|
||||
/// - SizedBox
|
||||
/// - Sliver 间距
|
||||
///
|
||||
/// 设计规范通常来源于 UI 设计系统,例如:
|
||||
/// 4 / 8 / 12 / 16 / 24 / 32
|
||||
class AppSpacing {
|
||||
/// 私有构造函数,防止实例化
|
||||
AppSpacing._();
|
||||
|
||||
// ================================
|
||||
// 基础间距 Token
|
||||
// ================================
|
||||
|
||||
/// 4px 间距(最小间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - icon 与文字之间
|
||||
/// - 紧凑布局
|
||||
static const double s4 = 4;
|
||||
|
||||
/// 8px 间距(小间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - 列表 item 内间距
|
||||
/// - 小组件之间
|
||||
static const double s8 = 8;
|
||||
|
||||
/// 12px 间距(中小间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - 表单组件
|
||||
/// - 信息块之间
|
||||
static const double s12 = 12;
|
||||
|
||||
/// 16px 间距(标准间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - 页面 Padding
|
||||
/// - Card 内边距
|
||||
static const double s16 = 16;
|
||||
|
||||
/// 24px 间距(大间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - 模块之间
|
||||
/// - Section 分隔
|
||||
static const double s24 = 24;
|
||||
|
||||
/// 32px 间距(超大间距)
|
||||
///
|
||||
/// 常用于:
|
||||
/// - 页面大区块
|
||||
/// - 顶部/底部留白
|
||||
static const double s32 = 32;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// # AppButton — 按钮原子组件(L2 Component)
|
||||
///
|
||||
@@ -117,12 +117,9 @@ class AppButton extends StatelessWidget {
|
||||
|
||||
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,
|
||||
backgroundColor: s.onSurface,
|
||||
foregroundColor: s.surface,
|
||||
);
|
||||
if (icon != null) {
|
||||
return FilledButton.icon(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/app_button.dart';
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
|
||||
/// # AppDialog — 业务确认弹窗(L3 Composite)
|
||||
///
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
|
||||
part 'get_profile_request.g.dart';
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
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';
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
|
||||
part 'login_request.g.dart';
|
||||
|
||||
/// # /auth/login — 登录接口
|
||||
/// # /app/api/auth/login-user — 使用 vcode_token 完成登录
|
||||
///
|
||||
/// 流程:发送验证码([SendOtpRequest])→ 校验验证码([VerifyOtpRequest])
|
||||
/// → ★ 用 vcode_token 登录(本请求)★ → 获得 access_token
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AuthRepositoryImpl.login(email, password)
|
||||
/// AuthRepositoryImpl.login(countryCode, contact, vcodeToken)
|
||||
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
|
||||
/// → 服务端 POST /auth/login
|
||||
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
|
||||
/// → ★ LoginResponse ★ = data 字段,T in APIResponseWrapper<T> ← 也在这里
|
||||
/// → 服务端 POST /app/api/auth/login-user
|
||||
/// → SDK 拆包 {code, message, data} envelope
|
||||
/// → ★ LoginResponse ★ ← 也在这里
|
||||
/// → LoginResponse.toEntity() → User
|
||||
/// ```
|
||||
|
||||
@@ -100,24 +103,27 @@ class LoginResponse {
|
||||
@JsonKey(name: 'account_id')
|
||||
final String accountId;
|
||||
final LoginProfile profile;
|
||||
final String nonce;
|
||||
@JsonKey(name: 'access_token')
|
||||
final String accessToken;
|
||||
@JsonKey(name: 'refresh_token')
|
||||
final String refreshToken;
|
||||
@JsonKey(name: 'device_id')
|
||||
final String deviceId;
|
||||
final String nonce;
|
||||
@JsonKey(name: 'login_data')
|
||||
final String loginData;
|
||||
@JsonKey(name: 'is_verified')
|
||||
final bool? isVerified;
|
||||
|
||||
const LoginResponse({
|
||||
required this.accountId,
|
||||
required this.profile,
|
||||
required this.nonce,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.deviceId,
|
||||
required this.loginData,
|
||||
this.nonce = '',
|
||||
this.loginData = '',
|
||||
this.isVerified,
|
||||
});
|
||||
|
||||
User toEntity() => profile.toEntity();
|
||||
@@ -127,11 +133,10 @@ class LoginResponse {
|
||||
// Request
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/// 登录请求
|
||||
/// 使用 vcode_token 完成登录的请求
|
||||
///
|
||||
/// `@ApiRequest` 一个注解搞定一切:
|
||||
/// - mixin 自动生成 path / method / requestType / includeToken / toJson
|
||||
/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
|
||||
/// 上游:[VerifyOtpRequest] 返回的 `token` 即 vcodeToken。
|
||||
/// 成功后 [LoginResponse.accessToken] 写入 ApiConfig,后续请求自动携带。
|
||||
@ApiRequest(
|
||||
path: ApiPaths.authLogin,
|
||||
method: HttpMethod.post,
|
||||
@@ -140,8 +145,15 @@ class LoginResponse {
|
||||
)
|
||||
class LoginRequest extends ApiRequestable<LoginResponse>
|
||||
with _$LoginRequestApi {
|
||||
final String email;
|
||||
final String password;
|
||||
@JsonKey(name: 'country_code')
|
||||
final String countryCode;
|
||||
final String contact;
|
||||
@JsonKey(name: 'vcode_token')
|
||||
final String vcodeToken;
|
||||
|
||||
LoginRequest({required this.email, required this.password});
|
||||
LoginRequest({
|
||||
required this.countryCode,
|
||||
required this.contact,
|
||||
required this.vcodeToken,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import '../../../core/foundation/api_paths.dart';
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
|
||||
part 'logout_request.g.dart';
|
||||
|
||||
|
||||
70
apps/im_app/lib/data/remote/send_otp_request.dart
Normal file
70
apps/im_app/lib/data/remote/send_otp_request.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
|
||||
part 'send_otp_request.g.dart';
|
||||
|
||||
/// 发送验证码响应
|
||||
///
|
||||
/// code: 0 时 [expiryTime] 有值;
|
||||
/// code: 30174(触发图片验证)时 [android] / [ios] / [web] / [extras] 有值。
|
||||
class SendOtpResponse {
|
||||
/// 验证码有效期(秒)
|
||||
@JsonKey(name: 'expiry_time')
|
||||
final int? expiryTime;
|
||||
|
||||
/// Android CAPTCHA SDK token
|
||||
final String? android;
|
||||
|
||||
/// iOS CAPTCHA SDK token
|
||||
final String? ios;
|
||||
|
||||
/// Web CAPTCHA SDK token
|
||||
final String? web;
|
||||
|
||||
/// CAPTCHA 平台扩展参数(预留字段)
|
||||
final Map<String, dynamic>? extras;
|
||||
|
||||
const SendOtpResponse({
|
||||
this.expiryTime,
|
||||
this.android,
|
||||
this.ios,
|
||||
this.web,
|
||||
this.extras,
|
||||
});
|
||||
}
|
||||
|
||||
/// # /app/api/auth/vcode/get — 发送手机验证码
|
||||
///
|
||||
/// 响应 `data: { "expiry_time": 180 }`,返回验证码有效期(秒)。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AuthRepositoryImpl.sendOtp(countryCode, contact)
|
||||
/// → _client.executeRequest( ★ SendOtpRequest ★ ) ← 你在这里
|
||||
/// → 服务端 POST /app/api/auth/vcode/get
|
||||
/// → 响应 { expiry_time: 180 } → SendOtpResponse
|
||||
/// ```
|
||||
@ApiRequest(
|
||||
path: ApiPaths.authSendOtp,
|
||||
method: HttpMethod.post,
|
||||
responseType: SendOtpResponse,
|
||||
requestType: ApiRequestType.login,
|
||||
)
|
||||
class SendOtpRequest extends ApiRequestable<SendOtpResponse>
|
||||
with _$SendOtpRequestApi {
|
||||
@JsonKey(name: 'country_code')
|
||||
final String countryCode;
|
||||
final String contact;
|
||||
|
||||
/// type=1 表示手机号验证
|
||||
final int type;
|
||||
|
||||
SendOtpRequest({
|
||||
required this.countryCode,
|
||||
required this.contact,
|
||||
this.type = 1,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'dart:typed_data';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import '../../../core/foundation/api_paths.dart';
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
|
||||
part 'upload_file_request.g.dart';
|
||||
|
||||
|
||||
61
apps/im_app/lib/data/remote/verify_otp_request.dart
Normal file
61
apps/im_app/lib/data/remote/verify_otp_request.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/core/foundation/api_paths.dart';
|
||||
|
||||
part 'verify_otp_request.g.dart';
|
||||
|
||||
/// 校验验证码接口的响应(服务端 data 字段)。
|
||||
///
|
||||
/// `token` 是 vcode_token,用于后续 login-user 请求换取 access_token。
|
||||
/// 纯 Dart 类,无需任何注解。`_$VerifyOtpResponseFromJson` 由生成器自动推导生成。
|
||||
class VerifyOtpResponse {
|
||||
/// 验证令牌,传给登录接口换取 access_token
|
||||
final String token;
|
||||
|
||||
const VerifyOtpResponse({required this.token});
|
||||
}
|
||||
|
||||
/// # /app/api/auth/vcode/check — 校验手机验证码
|
||||
///
|
||||
/// 校验成功后返回 [VerifyOtpResponse.token](即 vcode_token),
|
||||
/// 用于 [LoginRequest] 的 `vcode_token` 字段。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AuthRepositoryImpl.verifyOtp(countryCode, contact, code)
|
||||
/// → _client.executeRequest( ★ VerifyOtpRequest ★ ) ← 你在这里
|
||||
/// → 服务端 POST /app/api/auth/vcode/check
|
||||
/// → SDK 拆包 {code, message, data} envelope
|
||||
/// ← ★ VerifyOtpResponse ★ — token 即 vcode_token
|
||||
/// ```
|
||||
@ApiRequest(
|
||||
path: ApiPaths.authVerifyOtp,
|
||||
method: HttpMethod.post,
|
||||
responseType: VerifyOtpResponse,
|
||||
requestType: ApiRequestType.login,
|
||||
)
|
||||
class VerifyOtpRequest extends ApiRequestable<VerifyOtpResponse>
|
||||
with _$VerifyOtpRequestApi {
|
||||
@JsonKey(name: 'country_code')
|
||||
final String countryCode;
|
||||
final String contact;
|
||||
|
||||
/// 邮箱(手机号登录传空字符串)
|
||||
final String email;
|
||||
|
||||
/// 用户输入的验证码
|
||||
final String code;
|
||||
|
||||
/// type=1 表示手机号验证
|
||||
final int type;
|
||||
|
||||
VerifyOtpRequest({
|
||||
required this.countryCode,
|
||||
required this.contact,
|
||||
required this.code,
|
||||
this.email = '',
|
||||
this.type = 1,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,34 @@
|
||||
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';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||||
import 'package:im_app/data/remote/login_request.dart';
|
||||
import 'package:im_app/data/remote/logout_request.dart';
|
||||
import 'package:im_app/data/remote/send_otp_request.dart';
|
||||
import 'package:im_app/data/remote/verify_otp_request.dart';
|
||||
|
||||
/// 认证 Repository 实现
|
||||
///
|
||||
/// implements [AuthRepository] 接口(domain/repositories/ 中定义)。
|
||||
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
|
||||
/// 后续可加 Local DataSource 实现离线缓存。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
/// ## 登录流程
|
||||
///
|
||||
/// ```
|
||||
/// LoginUseCase.execute(email, password)
|
||||
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
|
||||
/// LoginUseCase.sendOtp(countryCode, contact)
|
||||
/// → ★ AuthRepositoryImpl.sendOtp() ★ ← 你在这里(步骤 1)
|
||||
/// → NetworksSdkApi.executeRequest(SendOtpRequest)
|
||||
/// → 服务端 POST /app/api/auth/otp/send
|
||||
///
|
||||
/// LoginUseCase.verifyAndLogin(countryCode, contact, code)
|
||||
/// → ★ AuthRepositoryImpl.verifyOtp() ★ ← 你在这里(步骤 2)
|
||||
/// → NetworksSdkApi.executeRequest(VerifyOtpRequest)
|
||||
/// → 服务端 POST /app/api/auth/vcode/check
|
||||
/// ← VerifyOtpResponse.token = vcode_token
|
||||
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里(步骤 3)
|
||||
/// → NetworksSdkApi.executeRequest(LoginRequest)
|
||||
/// → 服务端 POST /auth/login
|
||||
/// ← LoginResponse(SDK 已拆包 { code, message, data } envelope)
|
||||
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
|
||||
/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
|
||||
/// ← User(Domain Entity)
|
||||
/// → 服务端 POST /app/api/auth/login-user
|
||||
/// ← LoginResponse → _onTokenUpdate(accessToken) → User
|
||||
/// ```
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
final NetworksSdkApi _client;
|
||||
@@ -34,29 +41,62 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
_onTokenUpdate = onTokenUpdate;
|
||||
|
||||
@override
|
||||
Future<User> login({required String email, required String password}) async {
|
||||
final LoginResponse? loginResponse = await _client.executeRequest(
|
||||
LoginRequest(email: email, password: password),
|
||||
Future<void> sendOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
}) async {
|
||||
await _client.executeRequest(
|
||||
SendOtpRequest(countryCode: countryCode, contact: contact),
|
||||
);
|
||||
|
||||
if (loginResponse == null) {
|
||||
throw Exception('Login failed: empty response');
|
||||
}
|
||||
|
||||
_onTokenUpdate(loginResponse.accessToken);
|
||||
|
||||
return loginResponse.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User?> getCurrentUser() async {
|
||||
// TODO: 从本地存储获取用户信息
|
||||
return null;
|
||||
Future<String> verifyOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String code,
|
||||
}) async {
|
||||
final response = await _client.executeRequest(
|
||||
VerifyOtpRequest(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
code: code,
|
||||
),
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
throw Exception('Verify OTP failed: empty response');
|
||||
}
|
||||
|
||||
return response.token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User> login({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String vcodeToken,
|
||||
}) async {
|
||||
final response = await _client.executeRequest(
|
||||
LoginRequest(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
vcodeToken: vcodeToken,
|
||||
),
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
throw Exception('Login failed: empty response');
|
||||
}
|
||||
|
||||
_onTokenUpdate(response.accessToken);
|
||||
|
||||
return response.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
await _client.executeRequest(LogoutRequest());
|
||||
_onTokenUpdate(null); // 回调清除 Token(内存 + 持久化由 Provider 层组合)
|
||||
_onTokenUpdate(null); // 清除 Token(内存 + 持久化由 Provider 层组合)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
import '../entities/user.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
|
||||
/// 认证 Repository 接口(依赖倒置)
|
||||
///
|
||||
/// Domain 层定义 What,Data 层实现 How。
|
||||
/// ViewModel 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
|
||||
/// UseCase 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
/// ## 登录三步流程
|
||||
///
|
||||
/// ```
|
||||
/// ViewModel
|
||||
/// → ★ AuthRepository.login() ★ ← 你在这里(接口)
|
||||
/// → AuthRepositoryImpl.login() ← data/repositories/(实现)
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// → 服务端
|
||||
/// 1. sendOtp(countryCode, contact) → 发送验证码短信
|
||||
/// 2. verifyOtp(countryCode, contact, code) → 校验验证码,返回 vcode_token
|
||||
/// 3. login(countryCode, contact, vcodeToken) → 用 vcode_token 换 access_token,返回 User
|
||||
/// ```
|
||||
abstract interface class AuthRepository {
|
||||
/// 登录,返回 Domain Entity [User]
|
||||
Future<User> login({required String email, required String password});
|
||||
/// 发送手机验证码短信
|
||||
///
|
||||
/// 抛 [ApiError] 表示发送失败(手机号格式错误、频率限制等)。
|
||||
Future<void> sendOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
});
|
||||
|
||||
/// 获取当前登录用户信息
|
||||
Future<User?> getCurrentUser();
|
||||
/// 校验验证码,成功返回 vcode_token
|
||||
///
|
||||
/// vcode_token 用于 [login] 的 vcodeToken 参数。
|
||||
Future<String> verifyOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String code,
|
||||
});
|
||||
|
||||
/// 用 vcode_token 完成登录,返回 Domain Entity [User]
|
||||
///
|
||||
/// 成功后内部回调写入 access_token,后续请求自动携带。
|
||||
Future<User> login({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String vcodeToken,
|
||||
});
|
||||
|
||||
/// 退出登录
|
||||
Future<void> logout();
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:im_app/domain/entities/user.dart';
|
||||
/// 读取:DB row (DriftUser) → _toEntity() → Domain User
|
||||
/// 监听:DB 变化 → stream → Domain User → UI
|
||||
/// ```
|
||||
abstract class UserRepository {
|
||||
abstract interface class UserRepository {
|
||||
// ── 监听 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 监听单个用户,DB 变化自动反映
|
||||
|
||||
@@ -2,8 +2,8 @@ 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';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
part 'chat_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
|
||||
|
||||
import '../../../core/ui/components/app_button.dart';
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
|
||||
class ChatDbTestPage extends ConsumerStatefulWidget {
|
||||
const ChatDbTestPage({super.key});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 会话详情页(路由传参 Demo)
|
||||
///
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_view_model.dart';
|
||||
|
||||
/// 聊天页(Demo 按钮)
|
||||
///
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/app/di/user_provider.dart';
|
||||
import 'package:im_app/data/repositories/auth_repository_impl.dart';
|
||||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||||
import 'package:im_app/features/login/usecases/login_usecase.dart';
|
||||
|
||||
/// ## DI 装配:Auth Feature 层
|
||||
///
|
||||
@@ -23,6 +24,7 @@ import '../usecases/login_usecase.dart';
|
||||
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(userRepositoryProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
@@ -41,7 +43,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
// TODO: final secureStorage = ref.read(secureStorageProvider);
|
||||
|
||||
return AuthRepositoryImpl(
|
||||
client: ref.read(networkSdkApiProvider), // 注入 Facade 接口
|
||||
client: ref.read(networkSdkApiProvider),
|
||||
onTokenUpdate: (token) {
|
||||
apiConfig.updateToken(token); // 内存(network_sdk)
|
||||
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||
@@ -53,12 +55,13 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
|
||||
/// 登录用例 Provider
|
||||
///
|
||||
/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库
|
||||
/// 多步编排:格式校验 → 调接口 → 写 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),
|
||||
userRepository: ref.read(userRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../domain/entities/user.dart';
|
||||
|
||||
part 'login_state.freezed.dart';
|
||||
|
||||
/// 登录流程的当前步骤
|
||||
enum LoginStep {
|
||||
/// 步骤 1:输入手机号
|
||||
phone,
|
||||
|
||||
/// 步骤 2:输入验证码
|
||||
otp,
|
||||
}
|
||||
|
||||
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString)
|
||||
///
|
||||
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
|
||||
@@ -12,22 +19,43 @@ part 'login_state.freezed.dart';
|
||||
/// ## 状态流转
|
||||
///
|
||||
/// ```
|
||||
/// 初始 → 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
|
||||
/// 初始
|
||||
/// → LoginState() step: phone, isLoading: false
|
||||
/// 点击"获取验证码"
|
||||
/// → state.copyWith(isLoading: true)
|
||||
/// → 成功: state.copyWith(step: otp, contact: phone, isLoading: false)
|
||||
/// → 失败: state.copyWith(error: '...', isLoading: false)
|
||||
/// 点击"登录"
|
||||
/// → state.copyWith(isLoading: true)
|
||||
/// → 成功: authNotifierProvider.login() → 路由守卫重定向
|
||||
/// → 失败: state.copyWith(error: '...', isLoading: false)
|
||||
/// ```
|
||||
@freezed
|
||||
sealed class LoginState with _$LoginState {
|
||||
const factory LoginState({
|
||||
/// 登录成功后的用户信息(null = 未登录)
|
||||
User? user,
|
||||
const LoginState._();
|
||||
|
||||
/// 是否正在请求中(控制 loading 状态 / 按钮禁用)
|
||||
const factory LoginState({
|
||||
/// 当前步骤(手机号输入 or 验证码输入)
|
||||
@Default(LoginStep.phone) LoginStep step,
|
||||
|
||||
/// 国家代码(默认 +65,暂不支持切换)
|
||||
@Default('+65') String countryCode,
|
||||
|
||||
/// 已提交的手机号(步骤 2 用于显示和构建请求)
|
||||
@Default('') String contact,
|
||||
|
||||
/// 是否正在请求中
|
||||
@Default(false) bool isLoading,
|
||||
|
||||
/// 错误信息(null = 无错误)
|
||||
String? error,
|
||||
}) = _LoginState;
|
||||
|
||||
/// 步骤 2 显示的脱敏手机号,如 "138****0000"
|
||||
String get maskedContact {
|
||||
if (contact.length <= 4) return contact;
|
||||
final tail = contact.substring(contact.length - 4);
|
||||
final stars = '*' * (contact.length - 4);
|
||||
return '$stars$tail';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +1,103 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.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';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../di/auth_providers.dart';
|
||||
import 'login_state.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||
import 'package:im_app/features/login/presentation/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);
|
||||
/// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
|
||||
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
|
||||
/// ```
|
||||
///
|
||||
/// ## 手动 vs 自动 Provider 对比
|
||||
/// ## DI 链路
|
||||
///
|
||||
/// ```
|
||||
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
||||
/// loginViewModelProvider ← @riverpod 自动生成
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
|
||||
/// → ★ LoginViewModel.login() ★ ← 你在这里
|
||||
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
|
||||
/// → AuthRepository.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginResponse → User
|
||||
/// ← User
|
||||
/// → state = state.copyWith(user: user) ← 更新状态
|
||||
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
||||
/// ```
|
||||
@riverpod
|
||||
class LoginViewModel extends _$LoginViewModel {
|
||||
@override
|
||||
LoginState build() => const LoginState();
|
||||
|
||||
/// Demo 登录(跳过 API,直接设置登录状态)
|
||||
/// 步骤 1:发送手机验证码
|
||||
///
|
||||
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
||||
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
||||
Future<void> demoLogin() async {
|
||||
// 防止连点重入:第一次调用未完成前忽略后续调用
|
||||
/// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
|
||||
Future<void> sendOtp(String countryCode, String contact) 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);
|
||||
|
||||
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,
|
||||
await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.sendOtp(countryCode: countryCode, contact: contact);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
step: LoginStep.otp,
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
||||
await storageLifeCycle.openDatabase(user.uid);
|
||||
// Save user to DB via repository
|
||||
await repositoryProvider.insertOrReplaceUser(user);
|
||||
|
||||
// Trigger auth state
|
||||
provider.login();
|
||||
} catch (e) {
|
||||
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 设置 loading 状态(UI 显示加载指示器、禁用按钮)
|
||||
/// 2. 调 UseCase(格式校验 → 登录 → 返回 User)
|
||||
/// 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 provider.execute(email: email, password: password);
|
||||
state = state.copyWith(user: user, isLoading: false);
|
||||
} on FormatException catch (e) {
|
||||
// 格式校验失败(UseCase 层抛出)
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
// 网络 / 服务端错误(Repository → SDK 透传)
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
// 兜底:防止未预期的异常导致 isLoading 死锁
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤 2+3:校验验证码并完成登录
|
||||
///
|
||||
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向,provider 随即被 dispose。
|
||||
Future<void> verifyAndLogin(String code) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.verifyAndLogin(
|
||||
countryCode: state.countryCode,
|
||||
contact: state.contact,
|
||||
code: code,
|
||||
);
|
||||
|
||||
// 成功后触发路由守卫重定向。
|
||||
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state。
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login();
|
||||
} on FormatException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回手机号输入步骤(用户想修改手机号)
|
||||
void backToPhone() {
|
||||
state = state.copyWith(step: LoginStep.phone, error: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
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';
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||||
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||
|
||||
/// 登录用例
|
||||
///
|
||||
/// 封装登录的完整业务流程:
|
||||
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
|
||||
/// - sendOtp:格式校验 → 发短信
|
||||
/// - verifyAndLogin:格式校验 → 校验验证码 → 登录 → 初始化 WebSocket → 打开本地数据库
|
||||
///
|
||||
/// ## 为什么需要 UseCase?
|
||||
///
|
||||
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
|
||||
/// - 格式校验(不发无效请求,省流量、减少服务端压力)
|
||||
/// - 登录后初始化 WebSocket 连接
|
||||
/// - 登录后按 user id 打开对应的本地数据库
|
||||
///
|
||||
/// 把这些规则封装在 UseCase 里,ViewModel 只需一行调用。
|
||||
/// 登录有明确的多步业务规则,UseCase 把这些规则集中封装,
|
||||
/// ViewModel 只需一行调用。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// LoginViewModel.login(email, password)
|
||||
/// → ★ LoginUseCase.execute() ★ ← 你在这里
|
||||
/// → 格式校验(邮箱 + 密码)
|
||||
/// → AuthRepository.login()
|
||||
/// → AuthRepositoryImpl.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginResponse(SDK 已拆包 envelope)
|
||||
/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||
/// ← LoginResponse.toEntity() → User
|
||||
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
||||
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
||||
/// LoginViewModel.sendOtp(countryCode, contact)
|
||||
/// → ★ LoginUseCase.sendOtp() ★ ← 你在这里(步骤 1)
|
||||
/// → 格式校验(手机号)
|
||||
/// → AuthRepository.sendOtp()
|
||||
///
|
||||
/// LoginViewModel.verifyAndLogin(code)
|
||||
/// → ★ LoginUseCase.verifyAndLogin() ★ ← 你在这里(步骤 2+3)
|
||||
/// → 格式校验(验证码)
|
||||
/// → AuthRepository.verifyOtp() → vcode_token
|
||||
/// → AuthRepository.login() → User + token
|
||||
/// → SocketManager.connect(token)
|
||||
/// → StorageSdkApi.openDatabase(uid)
|
||||
/// → UserRepository.insertOrReplaceUser(user)
|
||||
/// ← User
|
||||
/// ```
|
||||
class LoginUseCase {
|
||||
@@ -40,6 +40,7 @@ class LoginUseCase {
|
||||
final SocketManager _socketManager;
|
||||
final ApiConfig _apiConfig;
|
||||
final StorageSdkApi _storageApi;
|
||||
final UserRepository _userRepository;
|
||||
|
||||
StorageSdkLifecycle get _storageLifeCycle =>
|
||||
_storageApi as StorageSdkLifecycle;
|
||||
@@ -49,67 +50,91 @@ class LoginUseCase {
|
||||
required SocketManager socketManager,
|
||||
required ApiConfig apiConfig,
|
||||
required StorageSdkApi storageApi,
|
||||
required UserRepository userRepository,
|
||||
}) : _authRepository = authRepository,
|
||||
_socketManager = socketManager,
|
||||
_apiConfig = apiConfig,
|
||||
_storageApi = storageApi;
|
||||
_storageApi = storageApi,
|
||||
_userRepository = userRepository;
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 格式校验 → 不合法直接抛 [FormatException]
|
||||
/// 2. 调 Repository 登录 → 拿到 User(token 写入由 Repository 处理)
|
||||
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
|
||||
/// 4. 按 user id 打开本地数据库
|
||||
/// 步骤 1:发送手机验证码
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 邮箱或密码格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误(由 Repository 透传)
|
||||
Future<User> execute({
|
||||
required String email,
|
||||
required String password,
|
||||
/// - [FormatException] — 手机号格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误
|
||||
Future<void> sendOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
}) async {
|
||||
// ── 1. 格式校验 ──
|
||||
_validateEmail(email);
|
||||
_validatePassword(password);
|
||||
_validatePhone(contact);
|
||||
await _authRepository.sendOtp(countryCode: countryCode, contact: contact);
|
||||
}
|
||||
|
||||
// ── 2. 登录 ──
|
||||
final user = await _authRepository.login(email: email, password: password);
|
||||
/// 步骤 2+3:校验验证码并完成登录,返回 [User]
|
||||
///
|
||||
/// 内部串行:verifyOtp → login → connectWebSocket → openDatabase → saveUser
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 验证码格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误
|
||||
Future<User> verifyAndLogin({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String code,
|
||||
}) async {
|
||||
_validateCode(code);
|
||||
|
||||
// ── 3. 连接 WebSocket ──
|
||||
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
||||
// 此处直接读取,避免改动现有接口。
|
||||
// 校验验证码,换取 vcode_token
|
||||
final vcodeToken = await _authRepository.verifyOtp(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
code: code,
|
||||
);
|
||||
|
||||
// 用 vcode_token 登录(token 写入由 Repository._onTokenUpdate 回调处理)
|
||||
final user = await _authRepository.login(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
vcodeToken: vcodeToken,
|
||||
);
|
||||
|
||||
// 连接 WebSocket(token 已由 Repository 写入 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);
|
||||
// 按用户 uid 打开本地数据库
|
||||
await _storageLifeCycle.openDatabase(user.uid);
|
||||
|
||||
// TODO: 后续扩展点
|
||||
// - 同步联系人列表
|
||||
// - 注册推送 token
|
||||
// 持久化登录用户信息
|
||||
await _userRepository.insertOrReplaceUser(user);
|
||||
|
||||
// TODO: 扩展点 — 同步联系人列表、注册推送 token
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
void _validateEmail(String email) {
|
||||
if (email.trim().isEmpty) {
|
||||
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
|
||||
void _validatePhone(String contact) {
|
||||
final trimmed = contact.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('手机号不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
if (!emailRegex.hasMatch(email.trim())) {
|
||||
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
|
||||
if (trimmed.length < 7 || trimmed.length > 15) {
|
||||
throw const FormatException('手机号长度不正确'); // TODO: 接入国际化
|
||||
}
|
||||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||||
throw const FormatException('手机号只能包含数字'); // TODO: 接入国际化
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePassword(String password) {
|
||||
if (password.isEmpty) {
|
||||
throw const FormatException('密码不能为空'); // TODO: 接入国际化
|
||||
void _validateCode(String code) {
|
||||
final trimmed = code.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('验证码不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
|
||||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||||
throw const FormatException('验证码只能包含数字'); // TODO: l10n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
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';
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
import 'package:im_app/features/login/presentation/login_view_model.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_otp_step.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_phone_step.dart';
|
||||
|
||||
/// 登录页(Demo)
|
||||
/// 登录页 — 两步流程:手机号 → 验证码
|
||||
///
|
||||
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin]
|
||||
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
|
||||
/// 步骤 1 [LoginStep.phone]:[LoginPhoneStep] — 输入国家代码 + 手机号
|
||||
/// 步骤 2 [LoginStep.otp]:[LoginOtpStep] — 输入验证码完成登录
|
||||
///
|
||||
/// 正式实现时替换为完整登录流程(email/password 输入 → LoginViewModel.login)。
|
||||
class LoginPage extends ConsumerWidget {
|
||||
/// 页面本身只持有两个 TextEditingController 和三个回调方法,
|
||||
/// 具体 UI 由 widgets/ 下的子组件负责。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者)
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
// demo 预填,上线前去掉
|
||||
final _phoneCtrl = TextEditingController(text: '83465308');
|
||||
final _otpCtrl = TextEditingController(text: '0000');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_otpCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendOtp(LoginState state) {
|
||||
ref
|
||||
.read(loginViewModelProvider.notifier)
|
||||
.sendOtp(state.countryCode, _phoneCtrl.text.trim());
|
||||
}
|
||||
|
||||
void _verifyAndLogin() {
|
||||
ref
|
||||
.read(loginViewModelProvider.notifier)
|
||||
.verifyAndLogin(_otpCtrl.text.trim());
|
||||
}
|
||||
|
||||
void _backToPhone() {
|
||||
_otpCtrl.clear();
|
||||
ref.read(loginViewModelProvider.notifier).backToPhone();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(loginViewModelProvider);
|
||||
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: state.isLoading
|
||||
? null
|
||||
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||
child: const Text('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('登录')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: state.step == LoginStep.phone
|
||||
? LoginPhoneStep(
|
||||
phoneCtrl: _phoneCtrl,
|
||||
state: state,
|
||||
onSendOtp: () => _sendOtp(state),
|
||||
)
|
||||
: LoginOtpStep(
|
||||
otpCtrl: _otpCtrl,
|
||||
state: state,
|
||||
onVerifyAndLogin: _verifyAndLogin,
|
||||
onBackToPhone: _backToPhone,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
/// 登录步骤 2 — 输入验证码并完成登录
|
||||
///
|
||||
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// LoginOtpStep(
|
||||
/// otpCtrl: _otpCtrl,
|
||||
/// state: state,
|
||||
/// onVerifyAndLogin: _verifyAndLogin,
|
||||
/// onBackToPhone: _backToPhone,
|
||||
/// )
|
||||
/// ```
|
||||
class LoginOtpStep extends StatelessWidget {
|
||||
const LoginOtpStep({
|
||||
super.key,
|
||||
required this.otpCtrl,
|
||||
required this.state,
|
||||
required this.onVerifyAndLogin,
|
||||
required this.onBackToPhone,
|
||||
});
|
||||
|
||||
final TextEditingController otpCtrl;
|
||||
final LoginState state;
|
||||
final VoidCallback onVerifyAndLogin;
|
||||
final VoidCallback onBackToPhone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'输入验证码',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'验证码已发送至 ${state.countryCode} ${state.maskedContact}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: otpCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '4 位验证码',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: state.isLoading ? null : onVerifyAndLogin,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: state.isLoading ? null : onBackToPhone,
|
||||
child: const Text('返回修改手机号'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
/// 登录步骤 1 — 输入国家代码 + 手机号
|
||||
///
|
||||
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// LoginPhoneStep(
|
||||
/// phoneCtrl: _phoneCtrl,
|
||||
/// state: state,
|
||||
/// onSendOtp: () => _sendOtp(state),
|
||||
/// )
|
||||
/// ```
|
||||
class LoginPhoneStep extends StatelessWidget {
|
||||
const LoginPhoneStep({
|
||||
super.key,
|
||||
required this.phoneCtrl,
|
||||
required this.state,
|
||||
required this.onSendOtp,
|
||||
});
|
||||
|
||||
final TextEditingController phoneCtrl;
|
||||
final LoginState state;
|
||||
final VoidCallback onSendOtp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'手机号登录',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
state.countryCode,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '手机号',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofillHints: const [AutofillHints.telephoneNumber],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: state.isLoading ? null : onSendOtp,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('获取验证码'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../usecases/set_theme_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
|
||||
|
||||
/// Settings feature DI 装配
|
||||
///
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
part 'settings_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../di/settings_providers.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
part 'theme_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../presentation/settings_view_model.dart';
|
||||
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
||||
|
||||
/// 设置页
|
||||
///
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
import 'package:im_app/features/settings/presentation/theme_view_model.dart';
|
||||
import 'package:im_app/features/settings/view/widgets/settings_section_header.dart';
|
||||
import 'package:im_app/features/settings/view/widgets/theme_option_tile.dart';
|
||||
|
||||
/// 主题选择页
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 设置页分组标题
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 单个主题选项行
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user