网络请求打通,ws 打通

This commit is contained in:
Cody
2026-03-09 19:05:55 +08:00
parent 997d821447
commit 3c1976b343
60 changed files with 1392 additions and 552 deletions

View File

@@ -1,4 +1,6 @@
{
"IS_DEV": true,
"API_BASE_URL": "https://dev-api.example.com"
"API_BASE_URL": "http://gateway.winwayinfo.com",
"CHANNEL": "15",
"APP_VERSION": "1.0.0"
}

View File

@@ -280,10 +280,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -474,6 +478,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -662,6 +667,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -690,6 +696,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;

View File

@@ -66,5 +66,18 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>gateway.winwayinfo.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>

View File

@@ -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';
/// 应用根组件
///

View File

@@ -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();

View File

@@ -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: 推送注册

View File

@@ -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 生命周期内唯一实例。
///

View File

@@ -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 时通常已传 tagdefaultTag 仅作兜底。
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 按需) │

View File

@@ -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
///

View File

@@ -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';
/// 登录守卫
///

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -0,0 +1,108 @@
import 'dart:io';
import 'dart:ui';
import 'package:device_info_plus/device_info_plus.dart';
/// 设备 / 运行时信息 — 用于构建 HTTP 请求头中的平台字段
///
/// 同步 getterplatform / 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;
}
/// 设备唯一标识
///
/// AndroidBuild.ID非持久化硬件 ID可作为临时标识如需稳定 ID 后续接入 android_id
/// iOSidentifierForVendor
/// macOSsystemGUID
/// WindowsdeviceId
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 '';
}
}

View File

@@ -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 tokenandroid / ios / web
static const int captchaRequired = 30174;
/// 强制登出错误码集合 — 触发退出登录流程
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
/// 踢下线错误码集合 — 触发踢下线 UI 提示
static const Set<int> kickOffCodes = {
loggedInAnotherDevice,
signingMethodError,
parsingKeyError,
};
}

View 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;
}
}
}

View File

@@ -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';
/// 消息预处理回调
///

View File

@@ -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
///

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'font.dart';
import 'package:im_app/core/ui/base/font.dart';
/// 主题样式快捷封装
///

View File

@@ -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(

View File

@@ -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
///

View File

@@ -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';

View File

@@ -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,
});
}

View File

@@ -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';

View 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,
});
}

View File

@@ -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';

View 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,
});
}

View File

@@ -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
/// ← LoginResponseSDK 已拆包 { code, message, data } envelope
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
/// ← UserDomain 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 层组合)
}
}

View File

@@ -1,25 +1,43 @@
import '../entities/user.dart';
import 'package:im_app/domain/entities/user.dart';
/// 认证 Repository 接口(依赖倒置)
///
/// Domain 层定义 WhatData 层实现 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();

View File

@@ -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';

View File

@@ -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});

View File

@@ -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
///

View File

@@ -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 按钮)
///

View File

@@ -1,10 +1,10 @@
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/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 层
///

View File

@@ -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';
}
}

View File

@@ -1,133 +1,101 @@
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.saveUser(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);
}
}

View File

@@ -1,38 +1,36 @@
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';
/// 登录用例
///
/// 封装登录的完整业务流程:
/// 格式校验 → 调 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)
/// LoginResponseSDK 已拆包 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)
/// ← User
/// ```
class LoginUseCase {
@@ -54,62 +52,84 @@ class LoginUseCase {
_apiConfig = apiConfig,
_storageApi = storageApi;
/// 执行登录
///
/// 1. 格式校验 → 不合法直接抛 [FormatException]
/// 2. 调 Repository 登录 → 拿到 Usertoken 写入由 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
///
/// 抛出:
/// - [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,
);
// 连接 WebSockettoken 已由 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
// 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
}
}
}

View File

@@ -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,
),
),
);
}

View File

@@ -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('返回修改手机号'),
),
],
);
}
}

View File

@@ -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('获取验证码'),
),
],
);
}
}

View File

@@ -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 装配
///

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';
/// 设置页
///

View File

@@ -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';
/// 主题选择页
///

View File

@@ -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';
/// 设置页分组标题
///

View File

@@ -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';
/// 单个主题选项行
///

View File

@@ -43,6 +43,9 @@ dependencies:
# 数据库schema 定义在 im_app连接/CRUD 封装在 storage_sdk
drift: ^2.22.0
# 设备信息deviceId / deviceName
device_info_plus: ^11.0.0
dev_dependencies:
flutter_test: