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:
@@ -2636,7 +2636,15 @@ class UploadFileRequest extends ApiRequestable<UploadResult>
|
||||
final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
return ApiConfig(
|
||||
baseURL: AppConfig.apiBaseUrl,
|
||||
platformHeaders: {'Platform': 'Android', 'client-version': '1.0.0'},
|
||||
platformHeaders: {
|
||||
'platform': DeviceInfo.platform, // 运行时同步读取(DeviceInfo.init() 预取)
|
||||
'os-type': DeviceInfo.osType.toString(), // Android=1 iOS=2 Windows=3 macOS=4 Linux=5
|
||||
'client-version': AppConfig.appVersion,
|
||||
'channel': AppConfig.channel,
|
||||
'lang': DeviceInfo.lang,
|
||||
'device-id': DeviceInfo.deviceId,
|
||||
'device-name': DeviceInfo.deviceName,
|
||||
},
|
||||
tokenExpiredCodes: {30002, 30003, 30124},
|
||||
forceLogoutCodes: {30125},
|
||||
onForceLogout: () { /* 清除登录态,跳转登录页 */ },
|
||||
@@ -6558,8 +6566,13 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
|
||||
return ApiConfig(
|
||||
baseURL: AppConfig.apiBaseUrl,
|
||||
platformHeaders: {
|
||||
'Platform': 'Android', // TODO: 运行时从平台 API 获取
|
||||
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
|
||||
'platform': DeviceInfo.platform, // 运行时同步读取(DeviceInfo.init() 预取)
|
||||
'os-type': DeviceInfo.osType.toString(), // Android=1 iOS=2 Windows=3 macOS=4 Linux=5
|
||||
'client-version': AppConfig.appVersion,
|
||||
'channel': AppConfig.channel,
|
||||
'lang': DeviceInfo.lang,
|
||||
'device-id': DeviceInfo.deviceId,
|
||||
'device-name': DeviceInfo.deviceName,
|
||||
},
|
||||
tokenExpiredCodes: {30002, 30003, 30124}, // 后端约定的 Token 过期错误码
|
||||
forceLogoutCodes: {30125}, // 后端约定的强制登出错误码
|
||||
@@ -7626,6 +7639,7 @@ linter:
|
||||
prefer_const_declarations: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
prefer_final_locals: true
|
||||
always_use_package_imports: true # 禁止相对路径 import,一律用 package: 全路径
|
||||
|
||||
# 命名规则
|
||||
camel_case_types: true
|
||||
@@ -9794,6 +9808,7 @@ flowchart TD
|
||||
<li><strong>使用 Melos 管理依赖</strong>:Mono-Repo 保证版本一致性</li>
|
||||
<li><strong>编写完整测试</strong>:单元测试、集成测试、端到端测试</li>
|
||||
<li><strong>UI 层使用 ConsumerWidget</strong>:通过 ref.watch 监听状态,ref.read 读取 Provider</li>
|
||||
<li><strong>import 一律使用 package 全路径</strong>:禁止相对路径(<code>../base/colors.dart</code>),统一写 <code>package:im_app/core/ui/base/colors.dart</code>;相对路径文件移动后静默失效,全路径重构安全、跨包引用统一,已由 <code>always_use_package_imports: true</code> 强制执行</li>
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,44 +1,73 @@
|
||||
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';
|
||||
|
||||
/// 单个主题选项行
|
||||
///
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,9 +21,9 @@ class AuthInterceptor extends Interceptor {
|
||||
options.extra['customHeaders'] as Map<String, String>?;
|
||||
|
||||
// 保留重试请求的原始 Request-ID(幂等性)
|
||||
// 重试时 options.headers 中已有 APP-Request-ID,
|
||||
// 重试时 options.headers 中已有 app-request-id,
|
||||
// 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。
|
||||
final existingRequestId = options.headers['APP-Request-ID'] as String?;
|
||||
final existingRequestId = options.headers['app-request-id'] as String?;
|
||||
|
||||
// 构建 headers
|
||||
final headers = config.defaultHeaders(
|
||||
@@ -33,7 +33,7 @@ class AuthInterceptor extends Interceptor {
|
||||
|
||||
// 还原原始 Request-ID
|
||||
if (existingRequestId != null) {
|
||||
headers['APP-Request-ID'] = existingRequestId;
|
||||
headers['app-request-id'] = existingRequestId;
|
||||
}
|
||||
|
||||
options.headers.addAll(headers);
|
||||
|
||||
@@ -3,8 +3,44 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 日志拦截器
|
||||
/// 统一打印请求 + 响应(一个日志块)
|
||||
/// 请求/响应日志拦截器
|
||||
///
|
||||
/// 每次请求打一条日志,请求和响应合并输出,方便对照:
|
||||
///
|
||||
/// ```
|
||||
/// [Network] ── SendOtpRequest (auth_repository_impl.dart:45)
|
||||
/// --> POST https://example.com/app/api/auth/otp/send
|
||||
/// Headers:
|
||||
/// {
|
||||
/// "Platform": "Android",
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// Body:
|
||||
/// {
|
||||
/// "country_code": "+65",
|
||||
/// "contact": "83465308",
|
||||
/// "type": 1
|
||||
/// }
|
||||
/// <-- 200 https://example.com/app/api/auth/otp/send
|
||||
/// {
|
||||
/// "code": 0,
|
||||
/// "message": "ok"
|
||||
/// }
|
||||
/// ────────────────────────────────────────────────────────────
|
||||
///
|
||||
/// [Network ERR] ── SendOtpRequest (auth_repository_impl.dart:45)
|
||||
/// --> POST https://example.com/app/api/auth/otp/send
|
||||
/// ...
|
||||
/// <-- 404 https://example.com/app/api/auth/otp/send
|
||||
/// Type: badResponse
|
||||
///
|
||||
/// <html>...</html>
|
||||
/// ────────────────────────────────────────────────────────────
|
||||
/// ```
|
||||
///
|
||||
/// Header 行显示 Request 类名 + 调用文件:行号,直接定位到出问题的调用代码。
|
||||
/// Footer 行 `────` 视觉上把相邻请求隔开,成对关系一眼看清。
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
final OnLog? onLog;
|
||||
final bool enabled;
|
||||
@@ -14,7 +50,12 @@ class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logRequestAndResponse(response);
|
||||
_log(
|
||||
opts: response.requestOptions,
|
||||
statusCode: response.statusCode,
|
||||
data: response.data,
|
||||
tag: 'Network',
|
||||
);
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
@@ -22,59 +63,103 @@ class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logError(err);
|
||||
_log(
|
||||
opts: err.requestOptions,
|
||||
statusCode: err.response?.statusCode,
|
||||
data: err.response?.data,
|
||||
errorType: err.type.name,
|
||||
errorMessage: err.message,
|
||||
tag: 'Network ERR',
|
||||
);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _logRequestAndResponse(Response response) {
|
||||
// ─── Log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void _log({
|
||||
required RequestOptions opts,
|
||||
required int? statusCode,
|
||||
required dynamic data,
|
||||
String? errorType,
|
||||
String? errorMessage,
|
||||
required String tag,
|
||||
}) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': response.requestOptions.uri.toString(),
|
||||
'method': response.requestOptions.method,
|
||||
'request': {
|
||||
if (response.requestOptions.data != null)
|
||||
'body': response.requestOptions.data,
|
||||
},
|
||||
'response': {
|
||||
'status': response.statusCode,
|
||||
if (response.data != null) 'body': response.data,
|
||||
},
|
||||
final buf = StringBuffer();
|
||||
const footer =
|
||||
'────────────────────────────────────────────────────────────';
|
||||
|
||||
// Header:Request 类名 + 调用位置
|
||||
final className = opts.extra['_requestClass'] as String? ?? '';
|
||||
final callerFrame = opts.extra['_callerFrame'] as String? ?? '';
|
||||
if (className.isNotEmpty) {
|
||||
buf.write('── $className');
|
||||
if (callerFrame.isNotEmpty) {
|
||||
buf.writeln(' ($callerFrame)');
|
||||
} else {
|
||||
buf.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
// Request:headers + body 合并成一个 JSON 块
|
||||
buf.writeln('--> ${opts.method} ${opts.uri}');
|
||||
final requestMap = <String, dynamic>{
|
||||
'headers': _sanitizeHeaders(opts.headers),
|
||||
if (opts.data != null) 'body': opts.data,
|
||||
};
|
||||
buf.writeln(_formatBody(requestMap));
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
// Response
|
||||
final status = statusCode != null ? '$statusCode' : 'ERR';
|
||||
buf.writeln('<-- $status ${opts.uri}');
|
||||
if (errorType != null) {
|
||||
buf.writeln('Type: $errorType');
|
||||
}
|
||||
if (data != null) {
|
||||
buf.writeln();
|
||||
buf.writeln(_formatBody(data));
|
||||
} else if (errorMessage != null && errorType == null) {
|
||||
// 无响应 body(超时、断网等),打印错误消息
|
||||
buf.writeln(errorMessage);
|
||||
}
|
||||
|
||||
// Footer
|
||||
buf.write(footer);
|
||||
|
||||
onLog!(buf.toString(), tag: tag);
|
||||
} catch (_) {
|
||||
onLog!(
|
||||
'API Request + Response:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!(
|
||||
'API: ${response.requestOptions.uri} -> ${response.statusCode}',
|
||||
tag: 'Network',
|
||||
'--> ${opts.method} ${opts.uri} | <-- $statusCode',
|
||||
tag: tag,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logError(DioException error) {
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Authorization token 只保留前 16 位,防止 token 泄露到日志
|
||||
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
|
||||
return headers.map((key, value) {
|
||||
if (key.toLowerCase() == 'authorization' &&
|
||||
value is String &&
|
||||
value.length > 16) {
|
||||
return MapEntry(key, '${value.substring(0, 16)}...');
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
/// 格式化 body,兼容 String / Map / List
|
||||
String _formatBody(dynamic data) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': error.requestOptions.uri.toString(),
|
||||
'method': error.requestOptions.method,
|
||||
'type': error.type.toString(),
|
||||
'message': error.message,
|
||||
if (error.response != null) ...{
|
||||
'status': error.response!.statusCode,
|
||||
'data': error.response!.data,
|
||||
},
|
||||
};
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
onLog!(
|
||||
'API Error:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!('API Error: ${error.message}', tag: 'Network');
|
||||
if (data is String) {
|
||||
final parsed = jsonDecode(data);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
} catch (_) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,24 @@ import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 重试拦截器
|
||||
///
|
||||
/// 两层重试机制:
|
||||
///
|
||||
/// 1. **Token 刷新重试**(onResponse)
|
||||
/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求
|
||||
/// 1. **业务错误码处理**(onResponse)
|
||||
/// 所有非 0 业务码经 [ApiConfig.onBusinessError] 回调,
|
||||
/// App 层返回 [BusinessErrorAction] 枚举告知 SDK 该怎么做:
|
||||
/// - refreshToken → 刷新 token 后重试
|
||||
/// - forceLogout → 中断请求
|
||||
/// - handled → App 已处理,不在 decodeResponse 中抛错
|
||||
/// - unhandled → 透传给调用方,decodeResponse 会抛 ApiError
|
||||
///
|
||||
/// 2. **瞬态错误重试**(onError)
|
||||
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
|
||||
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
|
||||
///
|
||||
/// 另外在 onResponse 中处理强制登出码和业务错误码。
|
||||
///
|
||||
/// 两层独立运作,可叠加。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final ApiConfig config;
|
||||
@@ -35,7 +39,7 @@ class RetryInterceptor extends Interceptor {
|
||||
proactiveRefreshThreshold: config.proactiveRefreshThreshold,
|
||||
);
|
||||
|
||||
// ── 响应处理(Token 过期 / 强制登出 / 业务错误码)──────────────────────
|
||||
// ── 响应处理(所有非 0 业务码统一走 onBusinessError)──────────────────
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
@@ -46,13 +50,39 @@ class RetryInterceptor extends Interceptor {
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final code = _parseCode(data['code']);
|
||||
if (code == 0) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
final message = data['message'] as String? ?? '';
|
||||
final requestPath = response.requestOptions.path;
|
||||
|
||||
// 检查强制登出
|
||||
if (config.forceLogoutCodes.contains(code)) {
|
||||
config.onLog?.call('Force logout detected (code: $code)', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
// 未注册 onBusinessError 时直接放行,由 decodeResponse 抛 ApiError 给调用方
|
||||
if (config.onBusinessError == null) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
final action = config.onBusinessError!(code, message, requestPath);
|
||||
switch (action) {
|
||||
case BusinessErrorAction.refreshToken:
|
||||
// 跳过已标记为 token 重试的请求,防止递归
|
||||
if (response.requestOptions.extra['_isTokenRetry'] == true) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
);
|
||||
_handleTokenExpired(response, handler);
|
||||
|
||||
case BusinessErrorAction.forceLogout:
|
||||
config.onLog?.call(
|
||||
'Force logout (code: $code)',
|
||||
tag: 'Network',
|
||||
);
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
@@ -60,33 +90,17 @@ class RetryInterceptor extends Interceptor {
|
||||
message: 'Force logout (code: $code)',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归)
|
||||
if (config.tokenExpiredCodes.contains(code) &&
|
||||
response.requestOptions.extra['_isTokenRetry'] != true) {
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
);
|
||||
_handleTokenExpired(response, handler);
|
||||
return;
|
||||
}
|
||||
|
||||
// 业务错误码拦截:非 0 且不在特殊码集合中
|
||||
if (code != 0 && config.onBusinessError != null) {
|
||||
final handled = config.onBusinessError!(code, message, requestPath);
|
||||
if (handled) {
|
||||
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
|
||||
case BusinessErrorAction.handled:
|
||||
// App 层已处理(弹窗 / Toast)→ 标记,让 decodeResponse 跳过二次抛错
|
||||
response.requestOptions.extra['_businessErrorHandled'] = true;
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case BusinessErrorAction.unhandled:
|
||||
// 未处理,decodeResponse 会抛 ApiError 给调用方
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 Token 过期:刷新 + 重试
|
||||
Future<void> _handleTokenExpired(
|
||||
@@ -97,7 +111,6 @@ class RetryInterceptor extends Interceptor {
|
||||
|
||||
if (newToken == null) {
|
||||
config.onLog?.call('Token refresh failed', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
|
||||
@@ -58,6 +58,9 @@ class NetworksSdkMethodChannelDataSource {
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
// 在首个 await 前捕获调用栈,async 间隙后栈信息会丢失
|
||||
final callerFrame = _callerFrame(StackTrace.current);
|
||||
|
||||
await _checkNetwork(request.path);
|
||||
|
||||
try {
|
||||
@@ -76,6 +79,8 @@ class NetworksSdkMethodChannelDataSource {
|
||||
'requestType': request.requestType,
|
||||
'includeToken': request.includeToken,
|
||||
'customHeaders': request.customHeaders,
|
||||
'_requestClass': request.runtimeType.toString(),
|
||||
'_callerFrame': callerFrame,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -275,6 +280,38 @@ class NetworksSdkMethodChannelDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从调用栈提取 App 层第一个帧(文件名:行号),用于日志定位
|
||||
///
|
||||
/// 仅 debug 模式栈帧可读;release 模式返回空字符串(由日志层静默忽略)。
|
||||
static String _callerFrame(StackTrace stack) {
|
||||
try {
|
||||
for (final line in stack.toString().split('\n')) {
|
||||
// 沿用已验证有效的过滤条件,不改动
|
||||
if (line.isEmpty ||
|
||||
line.contains('package:networks_sdk') ||
|
||||
line.contains('package:dio') ||
|
||||
line.contains('(dart:') || // dart: 内部帧格式是 (dart:async/...),不能用 'dart:' 否则会误杀 .dart:LINE
|
||||
line.contains('<asynchronous')) {
|
||||
continue;
|
||||
}
|
||||
// 用简单 regex 匹配 (URI:LINE:COL),已被证明可在 Android 上正常工作
|
||||
final fileMatch = RegExp(r'\((.+):(\d+):\d+\)').firstMatch(line);
|
||||
if (fileMatch == null) { continue; }
|
||||
|
||||
final fileName = fileMatch.group(1)!.split('/').last;
|
||||
final lineNum = fileMatch.group(2)!;
|
||||
|
||||
// 从括号前的文本里取最后一个单词作为 Symbol(可能带 #N 前缀)
|
||||
final before = line.substring(0, fileMatch.start).trim();
|
||||
final symbolMatch = RegExp(r'(\S+)$').firstMatch(before);
|
||||
final symbol = symbolMatch?.group(1) ?? '';
|
||||
|
||||
return symbol.isNotEmpty ? '$symbol · $fileName:$lineNum' : '$fileName:$lineNum';
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 应用响应变换(如果 App 层注入了 onTransformResponse)
|
||||
void _applyResponseTransform(Response response) {
|
||||
final transform = apiClient.config.onTransformResponse;
|
||||
|
||||
@@ -446,12 +446,12 @@ class SocketClient {
|
||||
// ping/pong 是传输层心跳,不经过业务加解密
|
||||
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
|
||||
_channel?.sink.add('ping');
|
||||
_log('♥ ping');
|
||||
_log('♥️ ping');
|
||||
|
||||
// 启动 pong 超时计时器
|
||||
_pongTimeoutTimer = Timer(config.pongTimeout, () {
|
||||
if (_waitingForPong) {
|
||||
_log('♥ pong timeout, reconnecting...');
|
||||
_log('♥️ pong timeout, reconnecting...');
|
||||
_waitingForPong = false;
|
||||
_emitError(const SocketError.pingTimeout());
|
||||
_doDisconnect(reason: 'Pong timeout');
|
||||
@@ -461,7 +461,7 @@ class SocketClient {
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_log('♥ pong');
|
||||
_log('♥️ pong');
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
|
||||
@@ -94,10 +94,9 @@ abstract class ApiRequestable<T> {
|
||||
final fromJsonFunc = fromJsonRegistry[T];
|
||||
|
||||
if (fromJsonFunc == null) {
|
||||
throw StateError(
|
||||
'fromJson not registered for type $T. '
|
||||
'Add: final _reg = registerResponse<$T>($T.fromJson);',
|
||||
);
|
||||
// void 接口:生成器不注册 fromJson,服务端 data 字段直接忽略
|
||||
// ignore: null_check_on_nullable_type_parameter
|
||||
return null as dynamic;
|
||||
}
|
||||
|
||||
if (fromJsonFunc is T Function(Object?)) {
|
||||
|
||||
@@ -97,7 +97,24 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
}
|
||||
|
||||
final className = element.name!;
|
||||
final path = annotation.read('path').stringValue;
|
||||
|
||||
// 尝试保留原始常量引用(如 ApiPaths.authSendOtp),
|
||||
// 这样修改 ApiPaths 里的值不需要重新跑 gen。
|
||||
// 若注解传的是字面量字符串,则回退为带引号字符串。
|
||||
final pathObject = annotation.read('path').objectValue;
|
||||
final pathVariable = pathObject.variable;
|
||||
final String pathExpression;
|
||||
if (pathVariable != null) {
|
||||
final enclosing = pathVariable.enclosingElement;
|
||||
if (enclosing is ClassElement) {
|
||||
pathExpression = '${enclosing.name}.${pathVariable.name!}';
|
||||
} else {
|
||||
pathExpression = pathVariable.name!;
|
||||
}
|
||||
} else {
|
||||
final pathValue = pathObject.toStringValue()!;
|
||||
pathExpression = "'$pathValue'";
|
||||
}
|
||||
|
||||
// 读取 HttpMethod 枚举值
|
||||
final methodName = _readEnumName(
|
||||
@@ -133,17 +150,21 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
|
||||
// 有响应类型:parameters getter 中注册 fromJson(使用生成的私有函数)
|
||||
// ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson,同 library 可访问
|
||||
// 无响应类型(void):跳过注册,直接返回 super.parameters
|
||||
final parametersBody = hasResponseType
|
||||
? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
|
||||
return super.parameters;'''
|
||||
: ' return super.parameters;';
|
||||
// 无响应类型(void):无需注册,不生成 parameters getter(避免 unnecessary_override)
|
||||
final parametersGetter = hasResponseType
|
||||
? '''
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
|
||||
return super.parameters;
|
||||
}'''
|
||||
: '';
|
||||
|
||||
return '''
|
||||
/// Generated by @ApiRequest for [$className]
|
||||
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
String get path => '$path';
|
||||
String get path => $pathExpression;
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.$methodName;
|
||||
@override
|
||||
@@ -151,11 +172,7 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
bool get includeToken => $includeToken;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => $toJsonBody;
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
$parametersBody
|
||||
}
|
||||
Map<String, dynamic> toJson() => $toJsonBody;$parametersGetter
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@ $params );
|
||||
if (type.isDartCoreDouble) return '$access as double$q';
|
||||
if (type.isDartCoreNum) return '$access as num$q';
|
||||
|
||||
// Map<String, dynamic>:已经是 JSON Map,直接 cast,无需 fromJson
|
||||
if (type.isDartCoreMap) return '$access as Map<String, dynamic>$q';
|
||||
|
||||
// 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数
|
||||
if (type is InterfaceType) {
|
||||
final typeName = type.element.name!;
|
||||
|
||||
@@ -17,9 +17,6 @@ class ApiConfig {
|
||||
/// Token 过期时的刷新回调
|
||||
final OnTokenRefresh? onTokenRefresh;
|
||||
|
||||
/// 需要强制登出时的回调
|
||||
final OnForceLogout? onForceLogout;
|
||||
|
||||
/// Token 更新后的通知回调
|
||||
///
|
||||
/// 在 [updateToken] 被调用且新 token 非空时触发。
|
||||
@@ -61,14 +58,6 @@ class ApiConfig {
|
||||
/// `{ code, data, message }` 结构。返回 null 表示不变换。
|
||||
final OnTransformResponse? onTransformResponse;
|
||||
|
||||
// ── 错误码集合 ──
|
||||
|
||||
/// App 层定义的 Token 过期错误码集合
|
||||
final Set<int> tokenExpiredCodes;
|
||||
|
||||
/// App 层定义的强制登出错误码集合
|
||||
final Set<int> forceLogoutCodes;
|
||||
|
||||
// ── 重试配置 ──
|
||||
|
||||
/// 瞬态错误最大重试次数(5xx / 超时 / 连接失败)
|
||||
@@ -110,7 +99,6 @@ class ApiConfig {
|
||||
this.token,
|
||||
this.platformHeaders = const {},
|
||||
this.onTokenRefresh,
|
||||
this.onForceLogout,
|
||||
this.onTokenUpdated,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
@@ -118,8 +106,6 @@ class ApiConfig {
|
||||
this.onDecryptResponse,
|
||||
this.onBusinessError,
|
||||
this.onTransformResponse,
|
||||
this.tokenExpiredCodes = const {},
|
||||
this.forceLogoutCodes = const {},
|
||||
this.maxRetries = 0,
|
||||
this.retryBaseDelay = const Duration(seconds: 1),
|
||||
this.tokenRefreshTimeout = const Duration(seconds: 10),
|
||||
@@ -136,12 +122,12 @@ class ApiConfig {
|
||||
Map<String, String>? customHeaders,
|
||||
}) {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Accept': 'application/json',
|
||||
'Keep-Alive': 'timeout=60',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'accept': 'application/json',
|
||||
'keep-alive': 'timeout=60',
|
||||
// Unix 时间戳(秒),整数值,非格式化日期字符串
|
||||
'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'APP-Request-ID': _generateRequestId(),
|
||||
'timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'app-request-id': _generateRequestId(),
|
||||
};
|
||||
|
||||
// 合并平台 headers(App 层注入的 version、platform 等)
|
||||
|
||||
@@ -8,9 +8,6 @@ import 'package:networks_sdk/src/domain/entities/encrypted_request.dart';
|
||||
/// Token 刷新回调,返回新 token;返回 null 表示刷新失败
|
||||
typedef OnTokenRefresh = Future<String?> Function();
|
||||
|
||||
/// 强制登出回调
|
||||
typedef OnForceLogout = void Function();
|
||||
|
||||
// ── Token 生命周期 ──
|
||||
|
||||
/// 获取 token 过期时间
|
||||
@@ -73,11 +70,27 @@ typedef OnDecryptResponse =
|
||||
|
||||
// ── 业务错误 ──
|
||||
|
||||
/// 业务错误拦截回调
|
||||
/// SDK 层收到 App 层对业务错误码的处置指令
|
||||
enum BusinessErrorAction {
|
||||
/// 刷新 token 后重试原请求(原 tokenExpiredCodes 行为)
|
||||
refreshToken,
|
||||
|
||||
/// 强制登出,中断当前请求(原 forceLogoutCodes 行为)
|
||||
forceLogout,
|
||||
|
||||
/// App 层已处理(如全局弹窗/Toast),SDK 正常放行响应,不在 decodeResponse 中抛错
|
||||
handled,
|
||||
|
||||
/// 未处理,SDK 继续正常流程,decodeResponse 会抛 ApiError 给调用方
|
||||
unhandled,
|
||||
}
|
||||
|
||||
/// 业务错误统一回调
|
||||
///
|
||||
/// App 层统一处理特定错误码,返回 true = 已处理(SDK 不再抛错),
|
||||
/// 返回 false = 未处理(SDK 继续正常流程)。
|
||||
typedef OnBusinessError = bool Function(int code, String message, String path);
|
||||
/// 所有非 0 业务码(token 过期、强制登出、踢下线、普通业务错误)全部经此入口,
|
||||
/// App 层通过返回 [BusinessErrorAction] 告诉 SDK 该怎么做。
|
||||
typedef OnBusinessError =
|
||||
BusinessErrorAction Function(int code, String message, String path);
|
||||
|
||||
/// 响应变换回调
|
||||
///
|
||||
|
||||
32
pubspec.lock
32
pubspec.lock
@@ -273,6 +273,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1148,6 +1164,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user