Initial project
This commit is contained in:
31
apps/im_app/lib/core/foundation/api_paths.dart
Normal file
31
apps/im_app/lib/core/foundation/api_paths.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
/// API 路径常量 — 全局统一管理
|
||||
///
|
||||
/// 所有 HTTP 接口路径在此定义,`@ApiRequest(path: ApiPaths.xxx)` 引用。
|
||||
/// 集中管理便于:搜索、重命名、接口迁移、后端对齐。
|
||||
///
|
||||
/// 命名规则:`模块_操作`,如 `authLogin`、`chatSendMessage`。
|
||||
///
|
||||
/// 新增路径时,先 Cmd+F 搜索路径值,确认不重复后再添加。
|
||||
// ignore: avoid_classes_with_only_static_members
|
||||
class ApiPaths {
|
||||
ApiPaths._();
|
||||
|
||||
// ── Auth ──
|
||||
static const authLogin = '/auth/login';
|
||||
static const authRefreshToken = '/auth/refresh-token';
|
||||
static const authLogout = '/auth/logout';
|
||||
|
||||
// ── User ──
|
||||
static const userProfile = '/user/profile';
|
||||
static const userUpdateProfile = '/user/update-profile';
|
||||
|
||||
// ── Chat ──
|
||||
static const chatSendMessage = '/chat/send-message';
|
||||
static const chatHistory = '/chat/history';
|
||||
|
||||
// ── Upload ──
|
||||
static const uploadFile = '/upload/file';
|
||||
|
||||
// ── WebSocket ──
|
||||
static const wsConnect = '/ws';
|
||||
}
|
||||
14
apps/im_app/lib/core/foundation/config.dart
Normal file
14
apps/im_app/lib/core/foundation/config.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
// 编译期从 --dart-define-from-file=config/config.json 注入
|
||||
// CI 打包时脚本修改 config.json 写入线上值,本地开发保持默认(IS_DEV=true)
|
||||
// ignore: avoid_classes_with_only_static_members
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true);
|
||||
static const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://dev-api.example.com',
|
||||
);
|
||||
|
||||
static bool get isProd => !isDev;
|
||||
}
|
||||
17
apps/im_app/lib/core/foundation/constants.dart
Normal file
17
apps/im_app/lib/core/foundation/constants.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
/// 全局常量
|
||||
///
|
||||
/// 跨模块共用的配置值集中管理,避免散落在各处导致不一致。
|
||||
class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
// ── 网络重试 ──
|
||||
|
||||
/// 最大重试次数(HTTP 瞬态错误 + WebSocket 重连 统一)
|
||||
static const maxRetries = 3;
|
||||
|
||||
/// 重试基础延迟(指数退避起点)
|
||||
static const retryBaseDelay = Duration(seconds: 1);
|
||||
|
||||
/// 重连最大延迟(指数退避上限)
|
||||
static const maxReconnectDelay = Duration(seconds: 30);
|
||||
}
|
||||
0
apps/im_app/lib/core/foundation/errors.dart
Normal file
0
apps/im_app/lib/core/foundation/errors.dart
Normal file
0
apps/im_app/lib/core/foundation/extensions.dart
Normal file
0
apps/im_app/lib/core/foundation/extensions.dart
Normal file
0
apps/im_app/lib/core/foundation/logger.dart
Normal file
0
apps/im_app/lib/core/foundation/logger.dart
Normal file
0
apps/im_app/lib/core/foundation/types.dart
Normal file
0
apps/im_app/lib/core/foundation/types.dart
Normal file
0
apps/im_app/lib/core/foundation/utils.dart
Normal file
0
apps/im_app/lib/core/foundation/utils.dart
Normal file
147
apps/im_app/lib/core/services/app_initializer.dart
Normal file
147
apps/im_app/lib/core/services/app_initializer.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// App 启动初始化器
|
||||
///
|
||||
/// 两阶段串行队列,确保启动流畅、资源不竞争。
|
||||
///
|
||||
/// ## 为什么不能在 initState 里一股脑 await?
|
||||
///
|
||||
/// 初始化任务并发执行会导致:
|
||||
/// - 多个 await 阻塞首帧渲染 → 白屏时间长
|
||||
/// - 任务间资源竞争(网络、IO、CPU)→ 互相拖慢
|
||||
/// - 一个任务失败可能阻塞后续所有任务
|
||||
///
|
||||
/// ## 解决方案:两阶段 + 串行队列
|
||||
///
|
||||
/// ```
|
||||
/// App 启动
|
||||
/// │
|
||||
/// ├── Phase 1: Critical(initState 中同步触发)
|
||||
/// │ 串行执行,必须在用户交互前完成。
|
||||
/// │ 只放真正阻塞用户操作的任务(尽量少)。
|
||||
/// │ 例:网络监听(后续所有网络操作依赖它)
|
||||
/// │
|
||||
/// ├── 首帧渲染(用户看到 UI)
|
||||
/// │
|
||||
/// └── Phase 2: Deferred(addPostFrameCallback 触发)
|
||||
/// 串行执行,首帧渲染后逐个跑。
|
||||
/// 不争抢资源,不影响 UI 流畅度。
|
||||
/// 例:推送注册、缓存预热、埋点 SDK 初始化
|
||||
/// ```
|
||||
///
|
||||
/// ## 添加新任务的规则
|
||||
///
|
||||
/// 问自己:「这个任务不完成,用户能正常看到首页吗?」
|
||||
/// - **能** → 放 deferred(绝大多数情况)
|
||||
/// - **不能** → 放 critical(谨慎添加,每多一个都会拖慢启动)
|
||||
///
|
||||
/// ## 设计原则
|
||||
///
|
||||
/// - **串行不并发**:同阶段内任务按顺序逐个执行,避免资源竞争
|
||||
/// - **隔离不传染**:每个任务独立 try-catch,一个失败不阻塞后续
|
||||
/// - **可观测**:每个任务计时 + 日志,方便排查启动瓶颈
|
||||
/// - **首帧优先**:deferred 阶段等首帧渲染完再开始
|
||||
class AppInitializer {
|
||||
/// 关键任务(首帧前串行执行)
|
||||
final List<InitTask> critical;
|
||||
|
||||
/// 延迟任务(首帧后串行执行)
|
||||
final List<InitTask> deferred;
|
||||
|
||||
/// 日志回调
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
|
||||
const AppInitializer({
|
||||
this.critical = const [],
|
||||
this.deferred = const [],
|
||||
this.onLog,
|
||||
});
|
||||
|
||||
/// 启动初始化
|
||||
///
|
||||
/// 1. 立即串行执行 critical 任务
|
||||
/// 2. 注册 addPostFrameCallback,首帧后串行执行 deferred 任务
|
||||
///
|
||||
/// 在 initState 中调用(fire-and-forget,不 await)。
|
||||
void run() {
|
||||
_runCritical();
|
||||
}
|
||||
|
||||
/// 执行关键阶段
|
||||
Future<void> _runCritical() async {
|
||||
if (critical.isEmpty) {
|
||||
_log('No critical tasks');
|
||||
} else {
|
||||
_log('── Critical phase: ${critical.length} task(s) ──');
|
||||
final totalSw = Stopwatch()..start();
|
||||
|
||||
await _runTasksSequentially(critical);
|
||||
|
||||
totalSw.stop();
|
||||
_log('── Critical phase done in ${totalSw.elapsedMilliseconds}ms ──');
|
||||
}
|
||||
|
||||
// 关键阶段完成后,注册首帧回调执行延迟阶段
|
||||
_scheduleDeferredPhase();
|
||||
}
|
||||
|
||||
/// 注册 addPostFrameCallback
|
||||
///
|
||||
/// 需要在 critical 完成后再注册,确保顺序:
|
||||
/// critical 完成 → 首帧渲染 → deferred 开始
|
||||
void _scheduleDeferredPhase() {
|
||||
if (deferred.isEmpty) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _runDeferred());
|
||||
}
|
||||
|
||||
/// 执行延迟阶段
|
||||
Future<void> _runDeferred() async {
|
||||
_log('── Deferred phase: ${deferred.length} task(s) ──');
|
||||
final totalSw = Stopwatch()..start();
|
||||
|
||||
await _runTasksSequentially(deferred);
|
||||
|
||||
totalSw.stop();
|
||||
_log('── Deferred phase done in ${totalSw.elapsedMilliseconds}ms ──');
|
||||
}
|
||||
|
||||
/// 串行执行任务队列
|
||||
///
|
||||
/// 逐个 await,不并发。每个任务独立 try-catch + 计时。
|
||||
Future<void> _runTasksSequentially(List<InitTask> tasks) async {
|
||||
for (var i = 0; i < tasks.length; i++) {
|
||||
final task = tasks[i];
|
||||
final sw = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
_log('[${i + 1}/${tasks.length}] ${task.name} ...');
|
||||
await task.task();
|
||||
sw.stop();
|
||||
_log('[${i + 1}/${tasks.length}] ${task.name} done (${sw.elapsedMilliseconds}ms)');
|
||||
} catch (e) {
|
||||
sw.stop();
|
||||
_log('[${i + 1}/${tasks.length}] ${task.name} FAILED (${sw.elapsedMilliseconds}ms): $e');
|
||||
// 不 rethrow — 隔离失败,继续执行后续任务
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
onLog?.call(message, tag: 'AppInit');
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化任务
|
||||
class InitTask {
|
||||
/// 任务名称(用于日志)
|
||||
final String name;
|
||||
|
||||
/// 任务执行体
|
||||
final Future<void> Function() task;
|
||||
|
||||
const InitTask({
|
||||
required this.name,
|
||||
required this.task,
|
||||
});
|
||||
}
|
||||
105
apps/im_app/lib/core/services/network_backoff_debouncer.dart
Normal file
105
apps/im_app/lib/core/services/network_backoff_debouncer.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
/// 网络恢复退避防抖器
|
||||
///
|
||||
/// 网络状态频繁切换(WiFi 不稳定、隧道信号间断等)时,
|
||||
/// 避免每次恢复都立即重连,用指数退避控制重连频率。
|
||||
///
|
||||
/// 增加 jitter 防止多设备同时重连的群体效应(thundering herd)。
|
||||
///
|
||||
/// ## 退避策略
|
||||
///
|
||||
/// - 首次触发 → 等 baseDelay 后执行
|
||||
/// - 短时间内再次触发 → 延迟翻倍(指数退避)
|
||||
/// - 长时间静默后触发 → 重置为 baseDelay(网络已稳定)
|
||||
///
|
||||
/// ## 退避进程(默认参数)
|
||||
///
|
||||
/// ```
|
||||
/// 触发 1 → 4s 后执行
|
||||
/// 触发 2 → 8s 后执行
|
||||
/// 触发 3 → 16s 后执行
|
||||
/// 触发 4 → 32s 后执行
|
||||
/// 触发 5 → 60s(封顶)
|
||||
/// ...静默超过 2 分钟...
|
||||
/// 触发 N → 4s(重置)
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// final debouncer = NetworkBackoffDebouncer();
|
||||
///
|
||||
/// // 网络恢复事件
|
||||
/// debouncer.call(() {
|
||||
/// socketManager.reconnect();
|
||||
/// });
|
||||
/// ```
|
||||
class NetworkBackoffDebouncer {
|
||||
/// 初始延迟
|
||||
final Duration baseDelay;
|
||||
|
||||
/// 退避上限
|
||||
final Duration maxDelay;
|
||||
|
||||
/// 静默多久后重置为初始延迟(网络已稳定,不再退避)
|
||||
final Duration resetThreshold;
|
||||
|
||||
/// 退避倍数
|
||||
final double factor;
|
||||
|
||||
Duration _currentDelay;
|
||||
DateTime? _lastTriggerTime;
|
||||
Timer? _timer;
|
||||
final _random = Random();
|
||||
|
||||
NetworkBackoffDebouncer({
|
||||
this.baseDelay = const Duration(seconds: 4),
|
||||
this.maxDelay = const Duration(seconds: 60),
|
||||
this.resetThreshold = const Duration(minutes: 2),
|
||||
this.factor = 2.0,
|
||||
}) : _currentDelay = baseDelay;
|
||||
|
||||
/// 触发退避执行
|
||||
///
|
||||
/// 取消上一个待执行的 action,按当前退避延迟重新计时。
|
||||
/// 短时间内多次触发只执行最后一次,延迟逐步递增。
|
||||
void call(void Function() action) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (_lastTriggerTime == null ||
|
||||
now.difference(_lastTriggerTime!) > resetThreshold) {
|
||||
// 首次触发 or 长时间静默 → 重置
|
||||
_currentDelay = baseDelay;
|
||||
} else {
|
||||
// 短时间内再次触发 → 退避
|
||||
final nextMs = (_currentDelay.inMilliseconds * factor).toInt();
|
||||
_currentDelay = Duration(
|
||||
milliseconds: nextMs < maxDelay.inMilliseconds
|
||||
? nextMs
|
||||
: maxDelay.inMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
_lastTriggerTime = now;
|
||||
|
||||
// 加 jitter(+0~25%),防止多设备同时重连
|
||||
final jitterMs = _random.nextInt((_currentDelay.inMilliseconds * 0.25).toInt().clamp(1, 15000));
|
||||
final delayWithJitter = _currentDelay + Duration(milliseconds: jitterMs);
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delayWithJitter, action);
|
||||
}
|
||||
|
||||
/// 取消待执行的 action
|
||||
void cancel() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
110
apps/im_app/lib/core/services/network_monitor.dart
Normal file
110
apps/im_app/lib/core/services/network_monitor.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// 网络状态监听
|
||||
///
|
||||
/// 基于 connectivity_plus 监听平台网络变化,
|
||||
/// 提供 [isConnected] 查询和 [onStatusChanged] 事件流。
|
||||
///
|
||||
/// 非单例,由 Riverpod Provider 构造注入。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// connectivity_plus(平台网络事件)
|
||||
/// → ★ NetworkMonitor ★ ← 你在这里
|
||||
/// → SocketManager.handleNetworkStatusChanged()
|
||||
/// → SocketClient.connect / disconnect
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// final monitor = ref.read(networkMonitorProvider);
|
||||
/// monitor.onStatusChanged.listen((isAvailable) {
|
||||
/// // 网络状态变化
|
||||
/// });
|
||||
/// ```
|
||||
class NetworkMonitor {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
StreamSubscription<List<ConnectivityResult>>? _subscription;
|
||||
|
||||
List<ConnectivityResult> _results = [];
|
||||
|
||||
final _statusController = StreamController<bool>.broadcast();
|
||||
|
||||
/// 日志输出回调
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
|
||||
NetworkMonitor({this.onLog});
|
||||
|
||||
/// 当前是否有网络连接
|
||||
///
|
||||
/// 排除无连接和仅蓝牙连接的情况。
|
||||
bool get isConnected {
|
||||
if (_results.isEmpty) return false;
|
||||
return !_results.contains(ConnectivityResult.none) &&
|
||||
!_results.every((r) => r == ConnectivityResult.bluetooth);
|
||||
}
|
||||
|
||||
/// 网络状态变化流
|
||||
///
|
||||
/// 只在连接状态真正改变时发送事件(connected ↔ disconnected),
|
||||
/// 同类型切换(WiFi → 4G)不会触发。
|
||||
Stream<bool> get onStatusChanged => _statusController.stream;
|
||||
|
||||
/// 初始化监听
|
||||
///
|
||||
/// App 启动时调用一次。获取当前状态并开始监听变化。
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
_results = await _connectivity.checkConnectivity();
|
||||
_log('Network status: $_connectionDescription');
|
||||
} catch (e) {
|
||||
_log('Failed to check connectivity: $e');
|
||||
_results = [ConnectivityResult.none];
|
||||
}
|
||||
|
||||
_subscription = _connectivity.onConnectivityChanged.listen(
|
||||
_onChanged,
|
||||
onError: (Object error) {
|
||||
_log('Connectivity listener error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_statusController.close();
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void _onChanged(List<ConnectivityResult> results) {
|
||||
final wasConnected = isConnected;
|
||||
_results = results;
|
||||
final nowConnected = isConnected;
|
||||
|
||||
_log('Network changed: $_connectionDescription');
|
||||
|
||||
// 只在真正切换时通知
|
||||
if (wasConnected != nowConnected) {
|
||||
_statusController.add(nowConnected);
|
||||
_log(nowConnected ? 'Network restored' : 'Network lost');
|
||||
}
|
||||
}
|
||||
|
||||
String get _connectionDescription {
|
||||
if (_results.contains(ConnectivityResult.wifi)) return 'WiFi';
|
||||
if (_results.contains(ConnectivityResult.mobile)) return 'Mobile';
|
||||
if (_results.contains(ConnectivityResult.ethernet)) return 'Ethernet';
|
||||
if (_results.contains(ConnectivityResult.none)) return 'None';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
onLog?.call(message, tag: 'Network');
|
||||
}
|
||||
}
|
||||
376
apps/im_app/lib/core/services/socket_manager.dart
Normal file
376
apps/im_app/lib/core/services/socket_manager.dart
Normal file
@@ -0,0 +1,376 @@
|
||||
import 'dart:async';
|
||||
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'network_backoff_debouncer.dart';
|
||||
|
||||
/// 消息预处理回调
|
||||
///
|
||||
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
|
||||
/// App 层在 Provider 装配时注入解密/解析逻辑,
|
||||
/// 不在 SDK 内部调用加解密 SDK。
|
||||
typedef MessageTransformer = Map<String, dynamic> Function(
|
||||
Map<String, dynamic> raw,
|
||||
);
|
||||
|
||||
/// WebSocket 连接管理
|
||||
///
|
||||
/// 在 SocketClient(SDK 底层能力)之上封装:
|
||||
/// - 连接/断连生命周期(登录连接、登出断连)
|
||||
/// - 前后台生命周期(后台断连省电、前台自动重连)
|
||||
/// - 网络状态响应(断网断连、恢复网络立即重连)
|
||||
/// - 操作前置检查(网络可用性 + 后台状态)
|
||||
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
|
||||
/// - 发送 API 透传
|
||||
///
|
||||
/// 不使用单例,通过 Riverpod Provider 注入。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// SocketClient.messageStream(原始消息)
|
||||
/// → onMessageTransform?(解密回调,App 层注入)
|
||||
/// → ★ SocketManager.messageStream ★ ← 你在这里
|
||||
/// → 业务模块消费
|
||||
/// ```
|
||||
///
|
||||
/// ## 生命周期流程
|
||||
///
|
||||
/// ```
|
||||
/// 登录成功 → connect(token) → 前置检查 → 建立连接
|
||||
/// App 进后台 → onEnterBackground() → 断开连接(省电)
|
||||
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
|
||||
/// 网络丢失 → handleNetworkLost() → 断开连接
|
||||
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
|
||||
/// 登出 → disconnect() → 断开连接,清除 token
|
||||
/// ```
|
||||
///
|
||||
/// ## 前置检查策略
|
||||
///
|
||||
/// 所有会发起网络操作的方法都先检查前置条件:
|
||||
/// - connect → 检查网络可用性 + 是否在后台
|
||||
/// - send / sendString → 检查连接状态 + 是否在后台
|
||||
/// - onEnterForeground 重连 → 检查网络可用性
|
||||
class SocketManager {
|
||||
final NetworksMessagingApi _client;
|
||||
final String _wsUrl;
|
||||
|
||||
/// 消息预处理回调
|
||||
///
|
||||
/// 登录后由 Provider 层注入,用于消息解密等。
|
||||
/// 不注入时直接透传原始消息。
|
||||
///
|
||||
/// TODO: 接入加解密 SDK 后实现
|
||||
final MessageTransformer? onMessageTransform;
|
||||
|
||||
/// 网络可用性查询(App 层注入)
|
||||
///
|
||||
/// 与 HTTP 层 [ApiConfig.onCheckNetworkAvailable] 对称。
|
||||
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
|
||||
final Future<bool> Function()? onCheckNetworkAvailable;
|
||||
|
||||
/// 日志回调
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
|
||||
// ── 内部状态 ──
|
||||
|
||||
/// 上次连接使用的 token,用于前台/网络恢复时自动重连
|
||||
String? _lastToken;
|
||||
|
||||
/// 后台断连标记:前台恢复时需要重连
|
||||
bool _reconnectOnForeground = false;
|
||||
|
||||
/// 断网标记:网络恢复时需要重连
|
||||
bool _reconnectOnNetworkRestore = false;
|
||||
|
||||
/// 当前是否在后台
|
||||
bool _isInBackground = false;
|
||||
|
||||
/// 网络恢复退避防抖器
|
||||
///
|
||||
/// 网络抖动(快速 offline → online 切换)时,
|
||||
/// 用指数退避避免反复锤服务器。
|
||||
/// 通过 [NetworkBackoffDebouncer] 实现指数退避。
|
||||
final NetworkBackoffDebouncer _networkDebouncer = NetworkBackoffDebouncer();
|
||||
|
||||
/// 前台恢复延迟重连定时器
|
||||
///
|
||||
/// 回前台后延迟 500ms 等待网络稳定再重连。
|
||||
/// 期间如果再次进后台 / 主动断连 / dispose,及时取消。
|
||||
Timer? _foregroundReconnectTimer;
|
||||
|
||||
SocketManager({
|
||||
required NetworksMessagingApi client,
|
||||
required String wsUrl,
|
||||
this.onMessageTransform,
|
||||
this.onCheckNetworkAvailable,
|
||||
this.onLog,
|
||||
}) : _client = client,
|
||||
_wsUrl = wsUrl;
|
||||
|
||||
// ── 连接 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 连接 WebSocket
|
||||
///
|
||||
/// 登录成功后调用,token 从登录响应获取。
|
||||
/// URL 由 Provider 层从 AppConfig 构建后注入,此处不关心来源。
|
||||
///
|
||||
/// 前置检查:
|
||||
/// - 在后台 → 跳过,标记前台恢复时重连
|
||||
/// - 无网络 → 跳过,标记网络恢复时重连
|
||||
Future<bool> connect({required String token}) async {
|
||||
_lastToken = token;
|
||||
_reconnectOnForeground = false;
|
||||
_reconnectOnNetworkRestore = false;
|
||||
|
||||
// 前置检查:在后台不连接(省电)
|
||||
if (_isInBackground) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('In background, defer connect to foreground');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 前置检查:无网络不连接
|
||||
if (!await _isNetworkAvailable()) {
|
||||
_reconnectOnNetworkRestore = true;
|
||||
_log('No network, defer connect to network restore');
|
||||
return false;
|
||||
}
|
||||
|
||||
_log('Connecting...');
|
||||
return _client.connect(_wsUrl, token: token);
|
||||
}
|
||||
|
||||
/// 断开连接(主动断连)
|
||||
///
|
||||
/// 登出时调用。清除 token,取消所有待执行的重连,不再自动重连。
|
||||
Future<void> disconnect() async {
|
||||
_lastToken = null;
|
||||
_reconnectOnForeground = false;
|
||||
_reconnectOnNetworkRestore = false;
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = null;
|
||||
_networkDebouncer.cancel();
|
||||
_log('Disconnecting (manual)');
|
||||
await _client.disconnect();
|
||||
}
|
||||
|
||||
/// 当前是否已连接
|
||||
bool get isConnected => _client.isConnected;
|
||||
|
||||
/// 当前连接状态
|
||||
SocketConnectionState get connectionState => _client.connectionState;
|
||||
|
||||
/// 当前是否在后台
|
||||
bool get isInBackground => _isInBackground;
|
||||
|
||||
// ── 前后台生命周期 ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 后台 → 断连(省电省流量)
|
||||
// 前台 → 自动重连(如果之前有连接)
|
||||
|
||||
/// App 进后台 → 断开连接,标记前台恢复时重连
|
||||
///
|
||||
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
|
||||
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
|
||||
void onEnterBackground() {
|
||||
_isInBackground = true;
|
||||
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = null;
|
||||
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
|
||||
_client.onEnterBackground();
|
||||
|
||||
if (_lastToken == null) return; // 未登录,无需处理
|
||||
|
||||
// 与 _handleNetworkLost 保持一致:
|
||||
// 不仅 connected,connecting / reconnecting 也要断开,
|
||||
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
|
||||
if (_client.isConnected ||
|
||||
_client.connectionState == SocketConnectionState.connecting ||
|
||||
_client.connectionState == SocketConnectionState.reconnecting) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('Entering background, disconnecting to save battery');
|
||||
_client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// App 回前台 → 自动重连(如果之前后台断连)
|
||||
///
|
||||
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
|
||||
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
|
||||
void onEnterForeground() {
|
||||
_isInBackground = false;
|
||||
_client.onEnterForeground();
|
||||
|
||||
if (_reconnectOnForeground && _lastToken != null) {
|
||||
_reconnectOnForeground = false;
|
||||
_log('Returning to foreground, reconnecting...');
|
||||
// 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = Timer(
|
||||
const Duration(milliseconds: 500),
|
||||
() async {
|
||||
_foregroundReconnectTimer = null;
|
||||
// 双重保险:回调执行时再次检查后台状态
|
||||
if (_isInBackground) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('Went back to background during delay, skip reconnect');
|
||||
return;
|
||||
}
|
||||
if (!_client.isConnected && _lastToken != null) {
|
||||
// 前置检查:网络可用性
|
||||
if (!await _isNetworkAvailable()) {
|
||||
_reconnectOnNetworkRestore = true;
|
||||
_log('Network unavailable, defer reconnect to network restore');
|
||||
return;
|
||||
}
|
||||
_client.connect(_wsUrl, token: _lastToken!);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 网络状态变化 ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// 网络丢失 → 断连(避免无效重试消耗资源)
|
||||
// 网络恢复 → 退避重连(防网络抖动)
|
||||
|
||||
/// 网络状态变化处理
|
||||
///
|
||||
/// 由 App 层 NetworkMonitor.onStatusChanged 事件驱动。
|
||||
void handleNetworkStatusChanged({required bool isAvailable}) {
|
||||
if (isAvailable) {
|
||||
_handleNetworkRestored();
|
||||
} else {
|
||||
_handleNetworkLost();
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络丢失 → 断开连接,标记网络恢复时重连
|
||||
///
|
||||
/// 断网后继续重试没有意义,主动断连避免无效重连消耗资源。
|
||||
void _handleNetworkLost() {
|
||||
if (_lastToken == null) return; // 未登录,无需处理
|
||||
|
||||
if (_client.isConnected ||
|
||||
_client.connectionState == SocketConnectionState.connecting ||
|
||||
_client.connectionState == SocketConnectionState.reconnecting) {
|
||||
_reconnectOnNetworkRestore = true;
|
||||
_log('Network lost, disconnecting');
|
||||
_client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络恢复 → 退避重连
|
||||
///
|
||||
/// 通过 [NetworkBackoffDebouncer] 控制重连频率,
|
||||
/// 网络抖动(快速 offline/online 切换)时不会反复锤服务器。
|
||||
///
|
||||
/// 退避进程:4s → 8s → 16s → 32s → 60s(封顶),
|
||||
/// 网络稳定超过 2 分钟后重置。
|
||||
void _handleNetworkRestored() {
|
||||
if (_reconnectOnNetworkRestore && _lastToken != null) {
|
||||
_reconnectOnNetworkRestore = false;
|
||||
|
||||
// 在后台不重连,等前台恢复时再连
|
||||
if (_isInBackground) {
|
||||
_reconnectOnForeground = true;
|
||||
_log('Network restored but in background, defer to foreground');
|
||||
return;
|
||||
}
|
||||
|
||||
_log('Network restored, scheduling reconnect with backoff');
|
||||
_networkDebouncer.call(() {
|
||||
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
|
||||
_log('Backoff timer fired, reconnecting');
|
||||
_client.connect(_wsUrl, token: _lastToken!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 消息流 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 处理后的 JSON 消息流
|
||||
///
|
||||
/// 经过 [onMessageTransform] 预处理(解密等)后的消息。
|
||||
/// 业务模块应监听此流,不直接监听 SocketClient.messageStream。
|
||||
Stream<Map<String, dynamic>> get messageStream {
|
||||
if (onMessageTransform != null) {
|
||||
return _client.messageStream.map(onMessageTransform!);
|
||||
}
|
||||
return _client.messageStream;
|
||||
}
|
||||
|
||||
/// 原始消息流(不经预处理,调试用)
|
||||
Stream<String> get rawMessageStream => _client.rawMessageStream;
|
||||
|
||||
/// 连接状态变化流
|
||||
Stream<SocketConnectionState> get connectionStateStream =>
|
||||
_client.connectionStateStream;
|
||||
|
||||
/// 错误流
|
||||
Stream<SocketError> get errorStream => _client.errorStream;
|
||||
|
||||
// ── 发送 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送 JSON 消息
|
||||
///
|
||||
/// 前置检查:未连接或在后台时不发送。
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
if (!_canSend()) return Future.value(false);
|
||||
return _client.send(message);
|
||||
}
|
||||
|
||||
/// 发送原始字符串
|
||||
///
|
||||
/// 前置检查:未连接或在后台时不发送。
|
||||
Future<bool> sendString(String message) {
|
||||
if (!_canSend()) return Future.value(false);
|
||||
return _client.sendString(message);
|
||||
}
|
||||
|
||||
// ── 释放 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 释放所有资源
|
||||
Future<void> dispose() {
|
||||
_foregroundReconnectTimer?.cancel();
|
||||
_foregroundReconnectTimer = null;
|
||||
_networkDebouncer.dispose();
|
||||
return _client.dispose();
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 发送前置检查
|
||||
///
|
||||
/// 两重保险:连接状态 + 后台状态。
|
||||
/// 后台已断连所以 isConnected 通常就能拦住,
|
||||
/// 但显式检查 _isInBackground 防止边界情况遗漏。
|
||||
bool _canSend() {
|
||||
if (!_client.isConnected) {
|
||||
_log('Not connected, cannot send');
|
||||
return false;
|
||||
}
|
||||
if (_isInBackground) {
|
||||
_log('In background, skip send');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 查询网络可用性
|
||||
///
|
||||
/// 未注入回调时默认网络可用(不阻塞操作)。
|
||||
Future<bool> _isNetworkAvailable() async {
|
||||
if (onCheckNetworkAvailable == null) return true;
|
||||
return onCheckNetworkAvailable!();
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
onLog?.call(message, tag: 'SocketManager');
|
||||
}
|
||||
}
|
||||
91
apps/im_app/lib/core/ui/base/app_theme.dart
Normal file
91
apps/im_app/lib/core/ui/base/app_theme.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
import 'font.dart';
|
||||
|
||||
/// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData
|
||||
///
|
||||
/// 同时提供 Light / Dark 双主题,按钮形状/颜色/字体统一在此定义,
|
||||
/// AppButton 只负责变体切换和 loading 逻辑,不硬编码颜色和字体。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AppColors + AppFont (L1 常量)
|
||||
/// → ★ AppTheme ★ (L1 组装) ← 你在这里
|
||||
/// → MaterialApp(theme: AppTheme.theme, darkTheme: AppTheme.darkTheme)
|
||||
/// → Theme.of(context) → 所有 Widget 自动响应主题变化
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // app/app.dart
|
||||
/// MaterialApp(
|
||||
/// theme: AppTheme.theme, // getter 名与 MaterialApp 参数名一一对应
|
||||
/// darkTheme: AppTheme.darkTheme,
|
||||
/// )
|
||||
/// ```
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
/// 亮色主题 — 对应 MaterialApp `theme:` 参数
|
||||
static ThemeData get theme => _build(Brightness.light);
|
||||
|
||||
/// 暗色主题 — 对应 MaterialApp `darkTheme:` 参数
|
||||
static ThemeData get darkTheme => _build(Brightness.dark);
|
||||
|
||||
static ThemeData _build(Brightness brightness) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final primary = isDark ? AppColors.primaryLight : AppColors.primary;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: ColorScheme(
|
||||
brightness: brightness,
|
||||
primary: primary,
|
||||
onPrimary: AppColors.white,
|
||||
secondary: primary,
|
||||
onSecondary: AppColors.white,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.white,
|
||||
surface: isDark ? AppColors.gray800 : AppColors.white,
|
||||
onSurface: isDark ? AppColors.white : AppColors.gray900,
|
||||
),
|
||||
scaffoldBackgroundColor: isDark ? AppColors.gray900 : AppColors.gray50,
|
||||
|
||||
// 字体
|
||||
textTheme: AppFont.textTheme(brightness),
|
||||
|
||||
// ElevatedButton → AppButton.primary
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.white,
|
||||
disabledBackgroundColor: AppColors.gray400,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
|
||||
// OutlinedButton → AppButton.secondary
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
side: BorderSide(color: primary),
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
|
||||
// TextButton → AppButton.text
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
apps/im_app/lib/core/ui/base/colors.dart
Normal file
40
apps/im_app/lib/core/ui/base/colors.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 颜色体系 — 与 Figma 设计稿对应
|
||||
///
|
||||
/// L1 基础常量 -- 不含任何 Widget,只输出颜色常量。
|
||||
/// View 层不直接引用 AppColors,通过 Theme.of(context) 访问语义色;
|
||||
/// 有特殊硬编码需求(插图、固定品牌色)时可直接引用。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AppColors(颜色常量)← 你在这里
|
||||
/// → AppTheme(组装为 ThemeData)
|
||||
/// → MaterialApp(注入)
|
||||
/// → Theme.of(context)(View 层消费)
|
||||
/// ```
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── Brand Primary ──────────────────────────────────────────────────────────
|
||||
static const primary = Color(0xFF2F80ED);
|
||||
static const primaryDark = Color(0xFF1A6BD4);
|
||||
static const primaryLight = Color(0xFF5BA3F5);
|
||||
|
||||
// ── Semantic ───────────────────────────────────────────────────────────────
|
||||
static const success = Color(0xFF27AE60);
|
||||
static const warning = Color(0xFFF2C94C);
|
||||
static const error = Color(0xFFEB5757);
|
||||
|
||||
// ── Neutral Gray Scale ─────────────────────────────────────────────────────
|
||||
static const white = Color(0xFFFFFFFF);
|
||||
static const gray50 = Color(0xFFF8F9FA);
|
||||
static const gray100 = Color(0xFFF1F3F4);
|
||||
static const gray200 = Color(0xFFE8EAED);
|
||||
static const gray400 = Color(0xFFBDC1C6);
|
||||
static const gray600 = Color(0xFF80868B);
|
||||
static const gray800 = Color(0xFF3C4043);
|
||||
static const gray900 = Color(0xFF202124);
|
||||
static const black = Color(0xFF000000);
|
||||
}
|
||||
89
apps/im_app/lib/core/ui/base/context_theme_ext.dart
Normal file
89
apps/im_app/lib/core/ui/base/context_theme_ext.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'font.dart';
|
||||
|
||||
/// 主题样式快捷封装
|
||||
///
|
||||
/// `context.styles` 返回此对象,build 方法里一行获取所有样式,
|
||||
/// 之后直接用 `s.bodySmall`、`s.primary`,不再写 Theme.of(context)。
|
||||
///
|
||||
/// ```dart
|
||||
/// final s = context.styles;
|
||||
///
|
||||
/// Text('标题', style: s.titleMedium)
|
||||
/// Text('描述', style: s.bodySmall)
|
||||
/// Icon(Icons.home, color: s.primary)
|
||||
/// Text('改色', style: s.bodySmall?.copyWith(color: s.primary))
|
||||
/// ```
|
||||
class AppStyles {
|
||||
AppStyles(BuildContext context)
|
||||
: _t = Theme.of(context).textTheme,
|
||||
_c = Theme.of(context).colorScheme;
|
||||
|
||||
final TextTheme _t;
|
||||
final ColorScheme _c;
|
||||
|
||||
// ── 字体 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
TextStyle? get displayLarge => _t.displayLarge;
|
||||
TextStyle? get displayMedium => _t.displayMedium;
|
||||
TextStyle? get displaySmall => _t.displaySmall;
|
||||
|
||||
TextStyle? get headlineLarge => _t.headlineLarge;
|
||||
TextStyle? get headlineMedium => _t.headlineMedium;
|
||||
TextStyle? get headlineSmall => _t.headlineSmall;
|
||||
|
||||
TextStyle? get titleLarge => _t.titleLarge;
|
||||
TextStyle? get titleMedium => _t.titleMedium;
|
||||
TextStyle? get titleSmall => _t.titleSmall;
|
||||
|
||||
TextStyle? get bodyLarge => _t.bodyLarge;
|
||||
TextStyle? get bodyMedium => _t.bodyMedium;
|
||||
TextStyle? get bodySmall => _t.bodySmall;
|
||||
|
||||
TextStyle? get labelLarge => _t.labelLarge;
|
||||
TextStyle? get labelMedium => _t.labelMedium;
|
||||
TextStyle? get labelSmall => _t.labelSmall;
|
||||
|
||||
// ── 颜色 + 亮暗 ───────────────────────────────────────────────────────────
|
||||
|
||||
Brightness get brightness => _c.brightness;
|
||||
bool get isDark => _c.brightness == Brightness.dark;
|
||||
|
||||
Color get primary => _c.primary;
|
||||
Color get onPrimary => _c.onPrimary;
|
||||
Color get secondary => _c.secondary;
|
||||
Color get onSecondary => _c.onSecondary;
|
||||
Color get error => _c.error;
|
||||
Color get onError => _c.onError;
|
||||
Color get surface => _c.surface;
|
||||
Color get onSurface => _c.onSurface;
|
||||
Color get outline => _c.outline;
|
||||
Color get outlineVariant => _c.outlineVariant;
|
||||
|
||||
// ── 预组合样式(字体 + 颜色,开箱即用)──────────────────────────────────────
|
||||
//
|
||||
// 与 AppButton 变体理念一致:按语义选用,无需手动拼 TextStyle 或 copyWith。
|
||||
// 新增场景时在此扩展,保持全局一致。
|
||||
|
||||
/// 分组标题 — 列表 Section、设置分组等(sectionLabel 字体 + primary 色)
|
||||
TextStyle get sectionLabel => AppFont.sectionLabel.copyWith(color: primary);
|
||||
|
||||
/// 辅助文字 — 元数据、次要信息、时间戳等(labelMedium + outline 色)
|
||||
TextStyle? get labelMuted => labelMedium?.copyWith(color: outline);
|
||||
|
||||
/// 正文次要 — 描述、提示等(bodySmall + outline 色)
|
||||
TextStyle? get bodyMuted => bodySmall?.copyWith(color: outline);
|
||||
|
||||
/// 错误提示 — 表单错误、警告等(bodySmall + error 色)
|
||||
TextStyle? get bodyError => bodySmall?.copyWith(color: error);
|
||||
}
|
||||
|
||||
/// BuildContext 主题入口
|
||||
///
|
||||
/// ```dart
|
||||
/// final s = context.styles;
|
||||
/// ```
|
||||
extension AppThemeX on BuildContext {
|
||||
AppStyles get styles => AppStyles(this);
|
||||
}
|
||||
215
apps/im_app/lib/core/ui/base/font.dart
Normal file
215
apps/im_app/lib/core/ui/base/font.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 字体体系 -- 与 Figma 设计稿对应
|
||||
///
|
||||
/// L1 基础常量 — 不含颜色,只定义字号/字重/行高/字距。
|
||||
/// View 层通过 [AppStyles](`context.styles`)消费,颜色由主题决定。
|
||||
/// 特殊场景(固定样式、不跟主题)可直接引用 AppFont。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// AppFont(字体常量)← 你在这里
|
||||
/// → AppTheme(组装为 TextTheme → ThemeData)
|
||||
/// → MaterialApp(注入)
|
||||
/// → context.styles(View 层消费)
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // 推荐:通过 context.styles 消费(自动响应亮暗主题)
|
||||
/// final s = context.styles;
|
||||
/// Text('标题', style: s.headlineMedium);
|
||||
/// Text('分组', style: s.sectionLabel); // 预组合:字体 + 主题色
|
||||
///
|
||||
/// // 特殊场景:固定样式,不跟主题切换
|
||||
/// Text('固定', style: AppFont.bodyMedium);
|
||||
/// ```
|
||||
class AppFont {
|
||||
AppFont._();
|
||||
|
||||
// ── 字体族 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 默认字体族(系统字体)
|
||||
///
|
||||
/// 接入自定义字体时只需修改此常量 + pubspec.yaml fonts 配置。
|
||||
static const String? _fontFamily = null; // null = 系统默认字体
|
||||
|
||||
// ── Display -- 超大展示(启动页、空状态大标题)──────────────────────────
|
||||
|
||||
static const displayLarge = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 57,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.25,
|
||||
height: 64 / 57,
|
||||
);
|
||||
|
||||
static const displayMedium = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 45,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 52 / 45,
|
||||
);
|
||||
|
||||
static const displaySmall = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 44 / 36,
|
||||
);
|
||||
|
||||
// ── Headline -- 页面标题(导航栏、Section 标题)────────────────────────
|
||||
|
||||
static const headlineLarge = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 40 / 32,
|
||||
);
|
||||
|
||||
static const headlineMedium = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 36 / 28,
|
||||
);
|
||||
|
||||
static const headlineSmall = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 32 / 24,
|
||||
);
|
||||
|
||||
// ── Title -- 卡片 / 列表标题(聊天列表名称、设置项标题)──────────────
|
||||
|
||||
static const titleLarge = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 28 / 22,
|
||||
);
|
||||
|
||||
static const titleMedium = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.15,
|
||||
height: 24 / 16,
|
||||
);
|
||||
|
||||
static const titleSmall = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
height: 20 / 14,
|
||||
);
|
||||
|
||||
// ── Body -- 正文内容(聊天气泡、表单输入、描述文字)──────────────────
|
||||
|
||||
static const bodyLarge = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.5,
|
||||
height: 24 / 16,
|
||||
);
|
||||
|
||||
static const bodyMedium = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.25,
|
||||
height: 20 / 14,
|
||||
);
|
||||
|
||||
static const bodySmall = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.4,
|
||||
height: 16 / 12,
|
||||
);
|
||||
|
||||
// ── Label -- 按钮 / 标签 / 辅助文字(按钮文字、Tab、Badge)──────────
|
||||
|
||||
static const labelLarge = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
height: 20 / 14,
|
||||
);
|
||||
|
||||
static const labelMedium = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
height: 16 / 12,
|
||||
);
|
||||
|
||||
static const labelSmall = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
height: 16 / 11,
|
||||
);
|
||||
|
||||
// ── 语义字体(超出 M3 标准级别的产品专属规格)────────────────────────────
|
||||
//
|
||||
// 这里只定义字号/字重/字距,不含颜色。
|
||||
// 颜色由 AppStyles 的预组合样式注入(如 AppStyles.sectionLabel)。
|
||||
|
||||
/// 分组标题:列表 Section、设置分组等(13 / w600 / 0.5 字距)
|
||||
static const sectionLabel = TextStyle(
|
||||
fontFamily: _fontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
);
|
||||
|
||||
// ── 组装 TextTheme(供 AppTheme 调用)──────────────────────────────────
|
||||
|
||||
/// 根据亮暗模式组装 TextTheme
|
||||
///
|
||||
/// 默认亮暗共用同一套字体规格。需要按模式区分时,
|
||||
/// 用 copyWith 覆盖个别样式即可,不影响其他级别。
|
||||
///
|
||||
/// 示例 -- 暗色模式下 labelLarge 改为 regular:
|
||||
/// ```dart
|
||||
/// labelLarge: isDark
|
||||
/// ? labelLarge.copyWith(fontWeight: FontWeight.w400)
|
||||
/// : labelLarge,
|
||||
/// ```
|
||||
///
|
||||
/// AppTheme._build() 中调用:
|
||||
/// ```dart
|
||||
/// textTheme: AppFont.textTheme(brightness),
|
||||
/// ```
|
||||
static TextTheme textTheme(Brightness brightness) {
|
||||
// final isDark = brightness == Brightness.dark;
|
||||
|
||||
return TextTheme(
|
||||
displayLarge: displayLarge,
|
||||
displayMedium: displayMedium,
|
||||
displaySmall: displaySmall,
|
||||
headlineLarge: headlineLarge,
|
||||
headlineMedium: headlineMedium,
|
||||
headlineSmall: headlineSmall,
|
||||
titleLarge: titleLarge,
|
||||
titleMedium: titleMedium,
|
||||
titleSmall: titleSmall,
|
||||
bodyLarge: bodyLarge,
|
||||
bodyMedium: bodyMedium,
|
||||
bodySmall: bodySmall,
|
||||
labelLarge: labelLarge,
|
||||
labelMedium: labelMedium,
|
||||
labelSmall: labelSmall,
|
||||
);
|
||||
}
|
||||
}
|
||||
141
apps/im_app/lib/core/ui/components/app_button.dart
Normal file
141
apps/im_app/lib/core/ui/components/app_button.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../base/context_theme_ext.dart';
|
||||
|
||||
/// # AppButton — 按钮原子组件(L2 Component)
|
||||
///
|
||||
/// 四种命名构造器对应四种变体,loading 状态自动禁用点击。
|
||||
/// 颜色和形状由 AppTheme 定义,AppButton 只做变体切换和 loading 逻辑。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// View 层 Widget 树
|
||||
/// → ★ AppButton.primary / .secondary / .text / .inverse ★ ← 你在这里
|
||||
/// → ElevatedButton / OutlinedButton / TextButton / FilledButton
|
||||
/// → AppTheme(颜色 / 形状已在 ThemeData 中定义)
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // 主按钮(全宽,填充色)
|
||||
/// AppButton.primary(label: '登录', onPressed: () => vm.login()),
|
||||
///
|
||||
/// // 加载状态(禁用点击,显示进度圈)
|
||||
/// AppButton.primary(label: '登录', onPressed: null, isLoading: true),
|
||||
///
|
||||
/// // 副按钮(描边)
|
||||
/// AppButton.secondary(label: '注册', onPressed: () {}),
|
||||
///
|
||||
/// // 文字按钮(非全宽)
|
||||
/// AppButton.text(label: '忘记密码?', onPressed: () {}),
|
||||
///
|
||||
/// // 反色按钮:亮色模式黑底白字,暗色模式白底黑字
|
||||
/// AppButton.inverse(
|
||||
/// label: '切换 Tab',
|
||||
/// icon: const Icon(Icons.swap_horiz),
|
||||
/// onPressed: () {},
|
||||
/// ),
|
||||
/// ```
|
||||
enum _ButtonVariant { primary, secondary, text, inverse }
|
||||
|
||||
class AppButton extends StatelessWidget {
|
||||
const AppButton.primary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.fullWidth = true,
|
||||
}) : _variant = _ButtonVariant.primary,
|
||||
icon = null;
|
||||
|
||||
const AppButton.secondary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.fullWidth = true,
|
||||
}) : _variant = _ButtonVariant.secondary,
|
||||
icon = null;
|
||||
|
||||
const AppButton.text({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.fullWidth = false,
|
||||
}) : _variant = _ButtonVariant.text,
|
||||
icon = null;
|
||||
|
||||
/// 反色按钮:颜色随明暗主题取反。
|
||||
///
|
||||
/// 亮色模式:黑色背景 + 白色文字。
|
||||
/// 暗色模式:白色背景 + 黑色文字。
|
||||
///
|
||||
/// 可选传 [icon](`Icon` widget),自动切换为带图标布局。
|
||||
const AppButton.inverse({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.fullWidth = false,
|
||||
this.icon,
|
||||
}) : _variant = _ButtonVariant.inverse;
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final bool fullWidth;
|
||||
|
||||
/// 仅 [AppButton.inverse] 使用,其余变体固定为 null
|
||||
final Widget? icon;
|
||||
|
||||
final _ButtonVariant _variant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = isLoading
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: Text(this.label);
|
||||
|
||||
final button = switch (_variant) {
|
||||
_ButtonVariant.primary =>
|
||||
ElevatedButton(onPressed: isLoading ? null : onPressed, child: label),
|
||||
_ButtonVariant.secondary =>
|
||||
OutlinedButton(onPressed: isLoading ? null : onPressed, child: label),
|
||||
_ButtonVariant.text =>
|
||||
TextButton(onPressed: isLoading ? null : onPressed, child: label),
|
||||
_ButtonVariant.inverse => _buildInverse(context, label),
|
||||
};
|
||||
|
||||
return fullWidth ? SizedBox(width: double.infinity, child: button) : button;
|
||||
}
|
||||
|
||||
Widget _buildInverse(BuildContext context, Widget label) {
|
||||
final s = context.styles;
|
||||
final isDark = s.isDark;
|
||||
final bg = isDark ? Colors.white : Colors.black;
|
||||
final fg = isDark ? Colors.black : Colors.white;
|
||||
final style = FilledButton.styleFrom(
|
||||
backgroundColor: bg,
|
||||
foregroundColor: fg,
|
||||
);
|
||||
if (icon != null) {
|
||||
return FilledButton.icon(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: style,
|
||||
icon: icon!,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
return FilledButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: style,
|
||||
child: label,
|
||||
);
|
||||
}
|
||||
}
|
||||
89
apps/im_app/lib/core/ui/composites/app_dialog.dart
Normal file
89
apps/im_app/lib/core/ui/composites/app_dialog.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../components/app_button.dart';
|
||||
|
||||
/// # AppDialog — 业务确认弹窗(L3 Composite)
|
||||
///
|
||||
/// 封装 showDialog,统一弹窗交互规范(标题 + 内容 + 确认/取消)。
|
||||
/// 内部使用 AppButton,展示 L3 → L2 → L1 的完整组合链路。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// View 层调用
|
||||
/// → AppDialog.show() ← 你在这里(静态入口)
|
||||
/// → showDialog<bool>
|
||||
/// → AppDialog widget(AlertDialog 布局)
|
||||
/// → AppButton.text(取消)
|
||||
/// → AppButton.primary(确认)
|
||||
/// ← Future<bool?> → true=确认, false=取消, null=点背景关闭
|
||||
/// ```
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // View 层
|
||||
/// final confirmed = await AppDialog.show(
|
||||
/// context,
|
||||
/// title: '删除联系人',
|
||||
/// content: '确定要删除该联系人吗?此操作不可恢复。',
|
||||
/// confirmLabel: '删除',
|
||||
/// );
|
||||
/// if (confirmed == true) {
|
||||
/// ref.read(contactViewModelProvider.notifier).deleteContact(id);
|
||||
/// }
|
||||
/// ```
|
||||
class AppDialog extends StatelessWidget {
|
||||
const AppDialog._({
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.confirmLabel,
|
||||
this.cancelLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
final String confirmLabel;
|
||||
final String? cancelLabel;
|
||||
|
||||
/// 显示确认弹窗
|
||||
///
|
||||
/// 返回:`true` = 确认,`false` = 取消,`null` = 点背景关闭
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmLabel = '确定', // TODO: 接入国际化
|
||||
String? cancelLabel = '取消', // TODO: 接入国际化
|
||||
bool barrierDismissible = true,
|
||||
}) =>
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (_) => AppDialog._(
|
||||
title: title,
|
||||
content: content,
|
||||
confirmLabel: confirmLabel,
|
||||
cancelLabel: cancelLabel,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
if (cancelLabel != null)
|
||||
AppButton.text(
|
||||
label: cancelLabel!,
|
||||
fullWidth: false,
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
AppButton.primary(
|
||||
label: confirmLabel,
|
||||
fullWidth: false,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user