chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板

Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库
7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系
5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter

通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import 'package:sunny_mochi/core/config/env.dart';
/// API 网络配置 — **baseUrl 决策的单一权威**
///
/// **对应 iOS BasicModule/Configuration/NetworkConfig.swift2026-05 同步)**
///
/// | 环境 | iOS apiBaseURL | Flutter ApiConfig.baseUrl |
/// |------|---------------|--------------------------|
/// | dev | http://192.168.1.201:24801 | 同 |
/// | test | https://dev.yixiong-tech.com:8081 | 同 |
/// | release | https://bac.new.hamkke.top | 同 |
///
/// **优先级**
/// 1. CI/CD / 临时调试通过 `--dart-define=API_BASE_URL=...` 注入 → 优先
/// 2. 否则按 `Env.name`dev/test/release)返回 iOS 同源 URL
///
/// **不再引入 h5BaseURL** — H5 评估报告已全部 Flutter 原生化(详见
/// docs/h5-to-native-decision-2026-05-10.md),不再需要 in-app WebView。
///
/// **响应码请用 [ResponseCode]**lib/core/network/response_code.dart)。
/// **API 路径请用 [ApiPaths]**lib/core/config/api_paths.dart)—
/// 不允许 datasource 散落字面量。
abstract class ApiConfig {
/// API 网关 baseUrl — 与 iOS NetworkConfig.swift 同源
static String get baseUrl {
// 1. dart-define 覆盖优先(CI/CD / 临时切换私有环境)
if (Env.apiBaseUrlOverride.isNotEmpty) {
return Env.apiBaseUrlOverride;
}
// 2. 按 Env.name 返回 iOS 同源 URL
return switch (Env.name) {
'dev' => 'http://192.168.1.201:24801',
'test' => 'https://dev.yixiong-tech.com:8081',
'release' => 'https://bac.new.hamkke.top',
_ => 'http://192.168.1.201:24801', // 默认 dev(与 iOS Debug 包一致)
};
}
// 网络超时 — 对应共性需求说明 §移动端 7.网络异常处理(10s 超时建议)
static const Duration connectTimeout = Duration(seconds: 15);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sendTimeout = Duration(seconds: 30);
}
+72
View File
@@ -0,0 +1,72 @@
/// API 路径常量(按微服务子系统分组)。
///
/// **零容忍规则**:所有 datasource 必须引用本文件常量;不允许字面量散落。
///
/// TODO: 填入项目 API 路径
abstract class ApiPaths {
// ============== Auth ==============
// TODO: 填入项目 API 路径
static const String authLoginSms = '';
static const String authLoginPwd = '';
// TODO: 填入项目 API 路径
static const String authSmsSend = '';
// TODO: 填入项目 API 路径
static const String authLogout = '';
// TODO: 填入项目 API 路径
static const String authRefresh = '';
// ============== User / Mine ==============
// TODO: 填入项目 API 路径
static const String userProfile = '';
// TODO: 填入项目 API 路径
static const String userChangePassword = '';
// TODO: 填入项目 API 路径
static const String userIdCardVerify = '';
// TODO: 填入项目 API 路径
static const String userPhoneList = '';
// TODO: 填入项目 API 路径
static const String userPhoneAdd = '';
// TODO: 填入项目 API 路径
static const String userPhoneDelete = '';
// TODO: 填入项目 API 路径
static const String userPhoneVerifyCode = '';
// TODO: 填入项目 API 路径
static const String userPhoneSetDefault = '';
// TODO: 填入项目 API 路径
static const String userAvatarUpload = '';
// TODO: 填入项目 API 路径
static const String userAvatarSetUrl = '';
// TODO: 填入项目 API 路径
static const String userPasswordVerify = '';
// TODO: 填入项目 API 路径
static const String userPasswordReset = '';
// ============== Home ==============
// TODO: 填入项目 API 路径
static const String homeBanner = '';
// ============== File ==============
// TODO: 填入项目 API 路径
static const String fileUpload = '';
// ============== Error Report ==============
// TODO: 填入项目 API 路径
static const String errorReport = '';
// ============== Crash Report ==============
// TODO: 填入项目 API 路径
static const String crashReport = '';
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart' show SentryFlutter;
/// 全局编译期环境配置。所有值通过 --dart-define 注入,避免明文 secrets。
///
/// **环境名与 iOS NetworkConfig.swift 对齐**dev / test / release
///
/// 用法:
/// ```bash
/// # 开发环境 + mock(默认)
/// flutter run --dart-define=ENV=dev --dart-define=USE_MOCK=true
///
/// # 测试环境(连真服 dev.yixiong-tech.com:8081
/// flutter run --dart-define=ENV=test
///
/// # 正式环境(连 bac.new.hamkke.top
/// flutter build apk --release --dart-define=ENV=release \
/// --dart-define=SENTRY_DSN=https://...@sentry/1
///
/// # CI/CD 临时覆盖 API URL(不修改源码)
/// flutter run --dart-define=API_BASE_URL=https://my-private.example.com
/// ```
///
/// 详见:
/// - `docs/flutter-architecture-design.md` §九.1 / §十一.5
/// - `docs/real-environment-verification.md`(环境就绪后填值)
/// - `lib/core/config/api_config.dart`baseUrl 决策权威)
abstract class Env {
/// 当前环境名:**dev / test / release**(与 iOS NetworkConfig 三态对齐)。默认 dev。
///
/// - dev:开发环境(局域网 192.168.1.201:24801 / mock 模式可用)
/// - test:测试环境(dev.yixiong-tech.com:8081 — 与 iOS test 同源)
/// - release:正式环境(bac.new.hamkke.top — Release 包应锁定此值)
static const String name = String.fromEnvironment('ENV', defaultValue: 'dev');
/// API baseUrl 临时覆盖(CI/CD / 私有环境调试用)。
///
/// **正常情况下不应使用此变量** — 让 [ApiConfig.baseUrl] 按 [name] 自动选择
/// iOS 同源 URL。仅当需要连私有/临时环境时通过 dart-define 注入。
///
/// ```bash
/// flutter run --dart-define=API_BASE_URL=https://my-private.example.com
/// ```
static const String apiBaseUrlOverride = String.fromEnvironment(
'API_BASE_URL',
);
/// Sentry DSN。**Q9 待答前**为空,[SentryFlutter.init] 自动跳过实际上报。
static const String sentryDsn = String.fromEnvironment(
'SENTRY_DSN',
);
/// 是否为内部测试包(决定 TalkerScreen 调试面板是否挂载)。
/// 详见 docs/real-environment-verification.md §M4 / §Talker 准入。
static const bool isInternalBuild = bool.fromEnvironment(
'INTERNAL_BUILD',
);
/// 是否启用 mock fixtures 路径(绕过真实网络请求)。
/// 详见 plan §Mock 与真实环境验证分层策略。
static const bool useMock = bool.fromEnvironment(
'USE_MOCK',
);
/// 腾讯云 IM SDKAppID(数字 ID)。
/// **Q3 待答前**为 0(无效值,init 会返回失败但不崩溃)。
/// 沙箱测试用控制台测试 SDKAppID;生产用真实业务 SDKAppID。
/// 注入:--dart-define=IM_SDK_APP_ID=1400xxxxxx
static const int imSdkAppId = int.fromEnvironment(
'IM_SDK_APP_ID',
);
/// RSA 公钥(DER-SPKI Base64)— 用于登录密码加密。
/// 默认值 = iOS dev 公钥(对应 platform-ios/.../RSAEncryption.swift 中的 publicKeyString)。
/// 生产 / Q6 答复后通过 --dart-define=RSA_PUBLIC_KEY=... 注入真实公钥。
static const String rsaPublicKey = String.fromEnvironment(
'RSA_PUBLIC_KEY',
defaultValue:
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB',
);
/// TLS 证书绑定指纹列表(SHA-256,多指纹支持轮换)。
/// **Q2 待答前**为空,CertificatePinningInterceptor 在空列表时跳过校验。
/// 真实指纹通过 --dart-define=PINNED_FINGERPRINTS=AA:BB,CC:DD 注入。
static List<String> get pinnedFingerprints {
const raw = String.fromEnvironment(
'PINNED_FINGERPRINTS',
);
if (raw.isEmpty) return const [];
return raw
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
// ---- 便捷判断(对齐 iOS NetworkConfig.Environment 三态)----
static bool get isDev => name == 'dev';
static bool get isTest => name == 'test';
static bool get isRelease => name == 'release';
/// 兼容旧调用 — 历史代码可能用 isProd 判断
/// @Deprecated 新代码请用 [isRelease]
static bool get isProd => isRelease;
/// Release 包除非 INTERNAL_BUILD=true,否则视为生产模式。
/// 用于 TalkerScreen / Riverpod observer 等开发面板的门控。
static bool get enableDevPanel => kDebugMode || isInternalBuild;
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/crash/pending_crash_report.dart';
import 'package:sunny_mochi/core/network/api_response.dart';
import 'package:sunny_mochi/core/network/dio_client.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'crash_datasource.g.dart';
@riverpod
CrashDatasource crashDatasource(Ref ref) =>
CrashDatasource(ref.watch(dioClientProvider));
/// 崩溃报告上报接口。
///
/// **后端接口规范(待后端实现)**:
/// ```
/// POST /sys/crash-report
/// Headers: satoken: <token>(可选,未登录也应接受)
/// Body: { "kind", "error", "stackTrace", "occurredAt", "deviceModel", ... }
/// Response: { "retCode": "00000", "retMsg": "ok", "retData": null }
/// ```
// TODO(backend): 实现 POST /sys/crash-report 接口(接收客户端崩溃日志)
class CrashDatasource {
const CrashDatasource(this._dio);
final Dio _dio;
Future<void> sendCrashReport(PendingCrashReport report) async {
final resp = await _dio.post<Map<String, dynamic>>(
ApiPaths.crashReport,
data: report.toJson(),
);
parseEnvelope(resp.data, (j) => j).unwrapVoid();
}
}
+52
View File
@@ -0,0 +1,52 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'crash_datasource.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(crashDatasource)
final crashDatasourceProvider = CrashDatasourceProvider._();
final class CrashDatasourceProvider
extends
$FunctionalProvider<CrashDatasource, CrashDatasource, CrashDatasource>
with $Provider<CrashDatasource> {
CrashDatasourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'crashDatasourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$crashDatasourceHash();
@$internal
@override
$ProviderElement<CrashDatasource> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
CrashDatasource create(Ref ref) {
return crashDatasource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CrashDatasource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CrashDatasource>(value),
);
}
}
String _$crashDatasourceHash() => r'4506810eb85819b314f44127ee944feb65b56b20';
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:sunny_mochi/core/crash/crash_datasource.dart';
import 'package:sunny_mochi/core/crash/pending_crash_report.dart';
import 'package:sunny_mochi/core/extensions/context_x.dart';
import 'package:sunny_mochi/core/widgets/app_toast.dart';
/// 崩溃报告对话框 — 下次启动时强制展示。
///
/// 用户体验:
/// - "发送报告"POST 上报 → 关闭弹窗,后台继续正常启动
/// - "跳过":直接关闭弹窗,继续正常启动
class CrashDialog extends ConsumerStatefulWidget {
const CrashDialog({super.key, required this.report});
final PendingCrashReport report;
@override
ConsumerState<CrashDialog> createState() => _CrashDialogState();
}
class _CrashDialogState extends ConsumerState<CrashDialog> {
bool _sending = false;
bool _showDetail = false;
Future<void> _send() async {
setState(() => _sending = true);
try {
await ref
.read(crashDatasourceProvider)
.sendCrashReport(widget.report);
if (mounted) {
Navigator.of(context).pop();
context.showToast('崩溃报告已发送,感谢您的反馈', type: ToastType.success);
}
} catch (e, st) {
if (mounted) {
setState(() => _sending = false);
context.showError(ref, e, st);
}
}
}
@override
Widget build(BuildContext context) {
final report = widget.report;
final time = report.occurredAtLocal;
final timeStr = time == null
? '未知时间'
: '${time.year}-${time.month.toString().padLeft(2, '0')}-'
'${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}';
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
title: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: const Color(0xFFFF9F0A),
size: 22.sp,
),
SizedBox(width: 8.w),
Text(
'上次发生了意外退出',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'应用在 $timeStr 崩溃退出。发送崩溃报告有助于我们修复问题,报告不包含您的个人数据。',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF636366),
height: 1.5,
),
),
SizedBox(height: 12.h),
_InfoRow('设备', '${report.deviceModel} / ${report.osVersion}'),
_InfoRow('版本', '${report.appVersion}+${report.appBuild}'),
SizedBox(height: 8.h),
GestureDetector(
onTap: () => setState(() => _showDetail = !_showDetail),
child: Row(
children: [
Text(
'错误详情',
style: TextStyle(
fontSize: 13.sp,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
Icon(
_showDetail
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 18.sp,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
if (_showDetail) ...[
SizedBox(height: 8.h),
Container(
constraints: BoxConstraints(maxHeight: 160.h),
decoration: BoxDecoration(
color: const Color(0xFFF2F2F7),
borderRadius: BorderRadius.circular(8.r),
),
child: SingleChildScrollView(
padding:
EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h),
child: Text(
'${report.error}\n\n${_shortStack(report.stackTrace)}',
style: TextStyle(
fontSize: 11.sp,
color: const Color(0xFF3A3A3C),
fontFamily: 'monospace',
height: 1.4,
),
),
),
),
],
],
),
actions: [
TextButton(
onPressed: _sending ? null : () => Navigator.of(context).pop(),
child: Text(
'跳过',
style: TextStyle(
color: const Color(0xFF8E8E93),
fontSize: 15.sp,
),
),
),
FilledButton(
onPressed: _sending ? null : _send,
child: _sending
? SizedBox(
width: 16.w,
height: 16.w,
child: const CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Text('发送报告', style: TextStyle(fontSize: 15.sp)),
),
],
);
}
String _shortStack(String stack) {
final lines = stack.split('\n');
if (lines.length <= 20) return stack;
return '${lines.take(20).join('\n')}\n...(${lines.length - 20} more lines)';
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36.w,
child: Text(
label,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFAEAEB2),
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF636366),
),
),
),
],
),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/crash/crash_dialog.dart';
import 'package:sunny_mochi/core/crash/crash_reporter.dart';
/// 应用根部崩溃检测门(wraps AppRoot)。
///
/// 在第一帧渲染完成后检查 [pendingCrashProvider],若非 null 则强制弹出
/// [CrashDialog]barrierDismissible: false)。
class CrashGate extends ConsumerStatefulWidget {
const CrashGate({super.key, required this.child});
final Widget child;
@override
ConsumerState<CrashGate> createState() => _CrashGateState();
}
class _CrashGateState extends ConsumerState<CrashGate> {
@override
void initState() {
super.initState();
final report = ref.read(pendingCrashProvider);
if (report != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => CrashDialog(report: report),
),
);
}
});
}
}
@override
Widget build(BuildContext context) => widget.child;
}
+129
View File
@@ -0,0 +1,129 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sunny_mochi/core/crash/pending_crash_report.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'crash_reporter.g.dart';
/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。
/// 默认值为 null(正常启动时无崩溃)。
@Riverpod(keepAlive: true)
PendingCrashReport? pendingCrash(Ref ref) => null;
/// 全局崩溃日志服务(纯静态,不依赖 Riverpod / Flutter framework)。
///
/// **生命周期**
/// 1. `preInit()` — main() 最早期调用,异步缓存路径 + 设备信息
/// 2. `consumePending()` — 读取并删除上次崩溃文件(一次性消费)
/// 3. `installHooks()` — 在 SentrySetup.init appRunner 内调用,链式挂载钩子
///
/// **崩溃写入**:仅用 `File.writeAsStringSync(flush: true)`,绝对不能 await。
abstract class CrashReporter {
static String? _crashFilePath;
static String _deviceModel = 'unknown';
static String _osVersion = 'unknown';
static String _appVersion = 'unknown';
static String _appBuild = '0';
/// 步骤 1:异步预初始化(收集路径 + 设备信息)。
static Future<void> preInit() async {
try {
final dir = await getApplicationDocumentsDirectory();
_crashFilePath = p.join(dir.path, 'crash_report.json');
final pkg = await PackageInfo.fromPlatform();
_appVersion = pkg.version;
_appBuild = pkg.buildNumber;
if (kIsWeb) return;
if (Platform.isAndroid) {
final info = await DeviceInfoPlugin().androidInfo;
_deviceModel = '${info.manufacturer} ${info.model}'.trim();
_osVersion = 'Android ${info.version.release}';
} else if (Platform.isIOS) {
final info = await DeviceInfoPlugin().iosInfo;
_deviceModel = info.utsname.machine;
_osVersion = '${info.systemName} ${info.systemVersion}';
} else {
_deviceModel = Platform.operatingSystem;
_osVersion = Platform.operatingSystemVersion;
}
} catch (_) {
// preInit 失败不能阻断启动流程
}
}
/// 步骤 2:一次性消费崩溃文件(读取 + 立即删除)。
/// 返回 null 表示本次是正常启动。
static PendingCrashReport? consumePending() {
final path = _crashFilePath;
if (path == null) return null;
final file = File(path);
if (!file.existsSync()) return null;
try {
final raw = file.readAsStringSync();
file.deleteSync();
return PendingCrashReport.fromRawJson(raw);
} catch (_) {
try {
file.deleteSync();
} catch (_) {}
return null;
}
}
/// 步骤 3:安装全局崩溃钩子(必须在 SentrySetup appRunner 内调用)。
///
/// 采用链式挂载:先读出 Sentry 已设置的 handler,我们包一层写回,
/// 确保 Sentry 依然能接到所有错误。
static void installHooks() {
final previousOnError = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
_writeCrashSync(kind: 'dart', error: error, stackTrace: stack);
return previousOnError?.call(error, stack) ?? false;
};
}
/// 同步写入崩溃文件(crash hook 专用,不能有任何 await)。
static void _writeCrashSync({
required String kind,
required Object error,
required StackTrace stackTrace,
}) {
final path = _crashFilePath;
if (path == null) return;
try {
final stackStr = stackTrace.toString();
final truncated = stackStr.length > 4096
? '${stackStr.substring(0, 4096)}\n...(truncated)'
: stackStr;
final report = PendingCrashReport(
kind: kind,
error: error.toString(),
stackTrace: truncated,
occurredAt: DateTime.now().toUtc().toIso8601String(),
deviceModel: _deviceModel,
osVersion: _osVersion,
appVersion: _appVersion,
appBuild: _appBuild,
);
File(path).writeAsStringSync(
report.toRawJson(),
flush: true,
);
} catch (_) {
// crash hook 内绝对不能抛出异常
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'crash_reporter.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。
/// 默认值为 null(正常启动时无崩溃)。
@ProviderFor(pendingCrash)
final pendingCrashProvider = PendingCrashProvider._();
/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。
/// 默认值为 null(正常启动时无崩溃)。
final class PendingCrashProvider
extends
$FunctionalProvider<
PendingCrashReport?,
PendingCrashReport?,
PendingCrashReport?
>
with $Provider<PendingCrashReport?> {
/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。
/// 默认值为 null(正常启动时无崩溃)。
PendingCrashProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pendingCrashProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pendingCrashHash();
@$internal
@override
$ProviderElement<PendingCrashReport?> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
PendingCrashReport? create(Ref ref) {
return pendingCrash(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PendingCrashReport? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PendingCrashReport?>(value),
);
}
}
String _$pendingCrashHash() => r'4ccef6df51aa7ed093a0ac08518772bbd70ecc4e';
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
/// 上次启动前崩溃的快照(从 crash_report.json 反序列化)。
///
/// 手写 fromJson/toJson — 文件写入发生在崩溃现场,不能依赖 code generator。
class PendingCrashReport {
const PendingCrashReport({
required this.kind,
required this.error,
required this.stackTrace,
required this.occurredAt,
required this.deviceModel,
required this.osVersion,
required this.appVersion,
required this.appBuild,
this.context,
});
/// 崩溃来源:dart(未捕获异常)/ zone(Zone 逃逸)
final String kind;
/// 原始异常 toString(技术细节,不展示给用户)
final String error;
/// 堆栈(已截断至 4096 字符)
final String stackTrace;
/// UTC ISO 8601
final String occurredAt;
final String deviceModel;
final String osVersion;
final String appVersion;
final String appBuild;
/// FlutterErrorDetails.context(仅 flutter 类型有值)
final String? context;
factory PendingCrashReport.fromJson(Map<String, dynamic> json) =>
PendingCrashReport(
kind: json['kind'] as String? ?? 'unknown',
error: json['error'] as String? ?? '',
stackTrace: json['stackTrace'] as String? ?? '',
occurredAt: json['occurredAt'] as String? ?? '',
deviceModel: json['deviceModel'] as String? ?? 'unknown',
osVersion: json['osVersion'] as String? ?? 'unknown',
appVersion: json['appVersion'] as String? ?? 'unknown',
appBuild: json['appBuild'] as String? ?? '0',
context: json['context'] as String?,
);
factory PendingCrashReport.fromRawJson(String raw) =>
PendingCrashReport.fromJson(jsonDecode(raw) as Map<String, dynamic>);
Map<String, dynamic> toJson() => {
'kind': kind,
'error': error,
'stackTrace': stackTrace,
'occurredAt': occurredAt,
'deviceModel': deviceModel,
'osVersion': osVersion,
'appVersion': appVersion,
'appBuild': appBuild,
if (context != null) 'context': context,
};
String toRawJson() => jsonEncode(toJson());
/// 崩溃时间(本地时区,用于 UI 展示)。
DateTime? get occurredAtLocal {
try {
return DateTime.parse(occurredAt).toLocal();
} catch (_) {
return null;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:asn1lib/asn1lib.dart';
import 'package:pointycastle/api.dart';
import 'package:pointycastle/asymmetric/api.dart';
import 'package:pointycastle/asymmetric/pkcs1.dart';
import 'package:pointycastle/asymmetric/rsa.dart';
/// RSA 公钥加密(对应 iOS BasicModule/Util/RSAEncryption.swift)。
///
/// - 算法:RSA / PKCS#1 v1.5 padding(与 iOS `SecKeyCreateEncryptedData` +
/// `.rsaEncryptionPKCS1` 完全对齐)
/// - 公钥格式:DER-SPKI Base64X.509 SubjectPublicKeyInfo
/// - 输出:标准 Base64(与服务端 `Base64.getDecoder()` 兼容)
///
/// 用法:
/// ```dart
/// final cipher = RsaHelper.encrypt(
/// 'myPassword',
/// publicKeyDerBase64: Env.rsaPublicKey,
/// );
/// ```
///
/// 真实公钥 = `Env.rsaPublicKey`--dart-define 注入;当前 Env 默认值 = iOS dev 公钥)。
class RsaHelper {
RsaHelper._();
/// 用公钥加密明文,返回 Base64 密文。
///
/// **R-ROB-2 / 2026-05-10**:失败必须抛 [RsaEncryptionException]**不允许返回 null** —
/// 健康类 App 加密失败必须显式中断业务流,不允许调用方 fallback 到明文(合规底线)。
/// 之前的 `String?` 签名依赖调用方人工 null 检查,新调用方易漏检导致请求送 "null" 串。
static String encrypt(
String plaintext, {
required String publicKeyDerBase64,
}) {
// R-ROB-2 / 2026-05-10:拒绝空明文 — _processInBlocks 对 0 字节输入会返回空密文
// (旧行为是 cipher='' 被 isNotNull 测试掩盖的 bug);业务侧密码也不应为空
if (plaintext.isEmpty) {
throw RsaEncryptionException('明文为空,拒绝加密');
}
final RSAPublicKey pubKey;
try {
pubKey = _parseSpkiBase64(publicKeyDerBase64);
} on Object catch (e, st) {
throw RsaEncryptionException(
'RSA 公钥解析失败:${e.runtimeType}',
cause: e,
stackTrace: st,
);
}
try {
final padding = PKCS1Encoding(RSAEngine())
..init(true, PublicKeyParameter<RSAPublicKey>(pubKey));
final input = Uint8List.fromList(utf8.encode(plaintext));
final output = _processInBlocks(padding, input);
return base64.encode(output);
} on Object catch (e, st) {
throw RsaEncryptionException(
'RSA 加密失败:${e.runtimeType}',
cause: e,
stackTrace: st,
);
}
}
/// 把 SPKI Base64 公钥(X.509 SubjectPublicKeyInfo)解析为 [RSAPublicKey]。
///
/// 失败抛错(不再 swallow)— 上层 [encrypt] 统一转换为 [RsaEncryptionException]。
static RSAPublicKey _parseSpkiBase64(String spkiBase64) {
final bytes = base64.decode(spkiBase64.replaceAll(RegExp(r'\s+'), ''));
final asn1Parser = ASN1Parser(bytes);
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
// SPKI: SEQUENCE { algorithm SEQUENCE { OID, NULL }, subjectPublicKey BIT STRING }
final publicKeyBitString = topLevelSeq.elements[1] as ASN1BitString;
final publicKeyAsn = ASN1Parser(publicKeyBitString.contentBytes());
final publicKeySeq = publicKeyAsn.nextObject() as ASN1Sequence;
final modulus = publicKeySeq.elements[0] as ASN1Integer;
final exponent = publicKeySeq.elements[1] as ASN1Integer;
return RSAPublicKey(
modulus.valueAsBigInteger,
exponent.valueAsBigInteger,
);
}
static Uint8List _processInBlocks(
AsymmetricBlockCipher engine,
Uint8List input,
) {
final numBlocks = (input.length / engine.inputBlockSize).ceil();
final output = Uint8List(numBlocks * engine.outputBlockSize);
var inputOffset = 0;
var outputOffset = 0;
while (inputOffset < input.length) {
final size = (inputOffset + engine.inputBlockSize <= input.length)
? engine.inputBlockSize
: input.length - inputOffset;
final processed = engine.process(
Uint8List.sublistView(input, inputOffset, inputOffset + size),
);
output.setRange(
outputOffset,
outputOffset + processed.length,
processed,
);
inputOffset += size;
outputOffset += processed.length;
}
return Uint8List.sublistView(output, 0, outputOffset);
}
}
/// RSA 加密失败异常 — 调用方应 catch 并转为业务层 [Failure](如 AuthFailure(cryptoFailed)),
/// **绝不允许** 把明文继续上送服务端。
class RsaEncryptionException implements Exception {
RsaEncryptionException(this.message, {this.cause, this.stackTrace});
final String message;
final Object? cause;
final StackTrace? stackTrace;
@override
String toString() => 'RsaEncryptionException: $message';
}
+94
View File
@@ -0,0 +1,94 @@
import 'dart:async';
/// Repository 层数据新鲜度策略 — 反馈文档 §"实时性分级的工程落地"。
///
/// **核心理念**:把"数据新鲜度"和"UI 可用性"解耦:
/// - 弱实时性场景(列表 / 档案)→ 优先返回缓存,后台刷新
/// - 强实时性场景(订单状态 / 支付结果)→ 优先实时,失败才降级
///
/// 在 Repository 方法签名里把策略当参数传入:
/// ```dart
/// Future<List<Order>> fetchOrders({
/// FreshnessPolicy policy = FreshnessPolicy.cacheFirst,
/// });
/// ```
/// UseCase / Notifier 决定使用哪种策略,Repository 内部用 [policyAwareFetch]
/// 实现统一的"读缓存 / 拉网络 / 写缓存"流程。
enum FreshnessPolicy {
/// 弱实时:先缓存,后台刷新。
/// 网络失败不影响 UI 显示(缓存兜底)。**列表页、档案展示首选**。
cacheFirst,
/// 强实时:先尝试网络,失败时才降级到缓存。
/// 适合订单状态、库存、消息计数等"过期数据会误导用户"的场景。
networkFirst,
/// 纯离线:只读缓存,不发起网络请求。
/// 适合电梯/地铁等已知无网场景,或用户主动选择离线模式。
cacheOnly,
/// 强制实时:只走网络,不读不写缓存。
/// 适合一次性操作(提交表单、确认下单)—— 缓存反而是污染源。
networkOnly,
}
/// 按 [FreshnessPolicy] 编排"读缓存 + 拉网络 + 写缓存"的统一流程。
///
/// **接入约定**
/// - [readCache]:从 Drift / Hive 读最近一份数据,**无缓存返回 null**(不要抛异常)
/// - [fetchRemote]:调 datasource 拉网络,失败抛 [Exception](被本函数 catch
/// - [writeCache]:把网络结果写回缓存(cacheOnly / networkOnly 不会触发)
///
/// **行为矩阵**
///
/// | 策略 | 读缓存 | 网络 | 网络失败 |
/// |------|------|------|---------|
/// | cacheFirst | 立即返回(如有)+ 后台刷新写回 | 后台 | 静默忽略(缓存已显示) |
/// | networkFirst | — | 前台 | 降级返回缓存(如有),无缓存则抛 |
/// | cacheOnly | 立即返回(如无返回 null) | 不发 | — |
/// | networkOnly | 不读 | 前台 | 抛 |
///
/// 返回 [Stream] 而非 Future 的原因:cacheFirst 场景 UI 需要先看到缓存、
/// 后台刷新后再看到最新值,自然是两次 emit。Drift 本身就支持 Stream
/// 让 UI 通过 StreamProvider 订阅即可。
Stream<T?> policyAwareFetch<T>({
required FreshnessPolicy policy,
required Future<T?> Function() readCache,
required Future<T> Function() fetchRemote,
required Future<void> Function(T value) writeCache,
}) async* {
switch (policy) {
case FreshnessPolicy.cacheOnly:
yield await readCache();
case FreshnessPolicy.networkOnly:
yield await fetchRemote();
case FreshnessPolicy.cacheFirst:
final cached = await readCache();
if (cached != null) yield cached;
try {
final fresh = await fetchRemote();
await writeCache(fresh);
yield fresh;
} on Object {
// 静默:UI 已显示缓存值,网络失败不打扰用户
// 业务侧若需提示"刷新失败",应在 Notifier 层用 ref.listen 单独监听网络层 Failure
if (cached == null) rethrow;
}
case FreshnessPolicy.networkFirst:
try {
final fresh = await fetchRemote();
await writeCache(fresh);
yield fresh;
} on Object catch (_) {
final cached = await readCache();
if (cached != null) {
yield cached;
} else {
rethrow;
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/crypto/rsa_helper.dart' show RsaEncryptionException;
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/network/response_code.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'exception_mapper.g.dart';
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
@Riverpod(keepAlive: true)
ExceptionMapper exceptionMapper(Ref ref) => const ExceptionMapper();
/// 把底层异常(DioException / DriftException / 其他)映射为应用层 [Failure]。
///
/// - 网络/HTTP → NetworkFailure / ServerFailure / AuthFailure
/// - 业务 envelope retCode 非成功 → ServerFailure / AuthFailure
/// - 数据库异常 → CacheFailure
/// - 兜底 → UnknownFailure
///
/// 详见 docs/flutter-architecture-design.md §四.2。
class ExceptionMapper {
const ExceptionMapper();
Failure fromDio(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout => NetworkFailure(
message: '网络超时,请稍后重试',
kind: NetworkFailureKind.timeout,
cause: e,
stackTrace: e.stackTrace,
),
DioExceptionType.connectionError => NetworkFailure(
message: '网络不可用,请检查连接',
kind: NetworkFailureKind.noNetwork,
cause: e,
stackTrace: e.stackTrace,
),
DioExceptionType.badCertificate => NetworkFailure(
message: '证书校验失败,可能存在中间人风险',
kind: NetworkFailureKind.badCertificate,
cause: e,
stackTrace: e.stackTrace,
),
DioExceptionType.cancel => NetworkFailure(
message: '请求已取消',
kind: NetworkFailureKind.cancelled,
cause: e,
stackTrace: e.stackTrace,
),
DioExceptionType.badResponse => _fromHttpStatus(e),
DioExceptionType.unknown => _fromUnknownDio(e),
};
}
Failure _fromHttpStatus(DioException e) {
final status = e.response?.statusCode;
if (status == 401) {
// 三态分类(与 TokenRefreshInterceptor 协作 — 反馈文档 §"Token 注销逻辑的编排"):
// 1. _auth_refresh_failed → refresh 用尽,必须重新登录
// 2. _auth_not_refreshable → 业务 401(如权限不足),不应跳登录
// 3. 其他 → 真 token 过期但未触发 refresh(理论不应到这里),按 unauthorized 兜底
final extra = e.requestOptions.extra;
if (extra['_auth_refresh_failed'] == true) {
return AuthFailure(
message: '登录状态已失效,请重新登录',
kind: AuthFailureKind.refreshFailed,
cause: e,
stackTrace: e.stackTrace,
);
}
if (extra['_auth_not_refreshable'] == true) {
return AuthFailure(
message: _businessAuthMessage(e) ?? '当前账号无访问权限',
kind: AuthFailureKind.forbidden,
cause: e,
stackTrace: e.stackTrace,
);
}
return AuthFailure(
message: '登录已过期,请重新登录',
cause: e,
stackTrace: e.stackTrace,
);
}
if (status == 403) {
return AuthFailure(
message: '无访问权限',
kind: AuthFailureKind.forbidden,
cause: e,
stackTrace: e.stackTrace,
);
}
// message 保留技术细节供 Sentry/TalkeruserMessage 由 ServerFailure getter 遮蔽
return ServerFailure(
message: e.message ?? 'HTTP $status',
statusCode: status,
cause: e,
stackTrace: e.stackTrace,
);
}
/// 从响应 body 中提取业务侧 message,给业务 401 一个更具体的提示。
String? _businessAuthMessage(DioException e) {
final body = e.response?.data;
if (body is! Map) return null;
final raw = body['retMsg'] ?? body['msg'] ?? body['message'];
final msg = raw is String ? raw : raw?.toString();
return (msg != null && msg.isNotEmpty) ? msg : null;
}
Failure _fromUnknownDio(DioException e) {
final inner = e.error;
if (inner is Failure) return inner;
return UnknownFailure(
message: e.message ?? '未知错误',
cause: e,
stackTrace: e.stackTrace,
);
}
/// 把业务 envelope 错误码映射为 Failure。
Failure fromBusinessCode(String? code, String? message) {
final friendly = ResponseCode.friendlyMessage(code, message ?? '请求失败');
if (ResponseCode.isUnauthorized(code)) {
return AuthFailure(message: friendly);
}
return ServerFailure(message: friendly, code: code);
}
/// Drift / 文件 IO 等本地异常 → CacheFailure。
Failure fromCache(Object error, [StackTrace? st]) {
return CacheFailure(
message: '本地数据访问失败:$error',
cause: error,
stackTrace: st,
);
}
/// 兜底(任意未识别异常)。
Failure fromUnknown(Object error, [StackTrace? st]) {
if (error is Failure) return error;
if (error is DioException) return fromDio(error);
// R-ROB-2 / 2026-05-10RSA 加密失败统一映射为 AuthFailure(cryptoFailed)
// 避免上层得到不友好的 'RsaEncryptionException: ...' 兜底文案
if (error is RsaEncryptionException) {
return AuthFailure(
message: '密码加密失败,请重试',
kind: AuthFailureKind.cryptoFailed,
cause: error,
stackTrace: st,
);
}
return UnknownFailure(
message: error.toString(),
cause: error,
stackTrace: st,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'exception_mapper.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
@ProviderFor(exceptionMapper)
final exceptionMapperProvider = ExceptionMapperProvider._();
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
final class ExceptionMapperProvider
extends
$FunctionalProvider<ExceptionMapper, ExceptionMapper, ExceptionMapper>
with $Provider<ExceptionMapper> {
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
ExceptionMapperProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'exceptionMapperProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$exceptionMapperHash();
@$internal
@override
$ProviderElement<ExceptionMapper> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ExceptionMapper create(Ref ref) {
return exceptionMapper(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ExceptionMapper value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ExceptionMapper>(value),
);
}
}
String _$exceptionMapperHash() => r'b02606666fb3e934dd54e4440c633471ab224c20';
+134
View File
@@ -0,0 +1,134 @@
import 'package:sunny_mochi/core/error/exception_mapper.dart'
show ExceptionMapper;
/// 应用层统一失败模型。
///
/// 所有数据/网络/缓存层的异常通过 [ExceptionMapper] 转换为 [Failure] 子类,
/// presentation 层只面对 Failure,避免散落 try-catch DioException / DriftException。
///
/// **实现 [Exception]**:让 datasource/repository 可以直接 `throw failure;`
/// 不触发 `only_throw_errors` lint。
///
/// 详见 docs/flutter-architecture-design.md §四.2。
sealed class Failure implements Exception {
const Failure({required this.message, this.cause, this.stackTrace});
final String message;
final Object? cause;
final StackTrace? stackTrace;
/// 子类自我描述前缀(避免直接用 [Object.runtimeType]Release 混淆友好)。
String get _typeName;
/// 适合展示给用户的文案(区别于 [message],后者含技术细节,供 Sentry/Talker 日志使用)。
String get userMessage;
@override
String toString() => '$_typeName: $message';
}
/// 网络层失败:超时 / 无网 / DNS 解析失败 / 证书绑定失败 / 连接断开。
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
this.kind = NetworkFailureKind.unknown,
super.cause,
super.stackTrace,
});
final NetworkFailureKind kind;
@override
String get _typeName => 'NetworkFailure';
@override
String get userMessage => message;
}
enum NetworkFailureKind {
timeout,
noNetwork,
badCertificate,
cancelled,
unknown,
}
/// 鉴权失败:未登录 / Token 过期 / refresh 失败 → 应跳登录。
class AuthFailure extends Failure {
const AuthFailure({
required super.message,
this.kind = AuthFailureKind.unauthorized,
super.cause,
super.stackTrace,
});
final AuthFailureKind kind;
@override
String get _typeName => 'AuthFailure';
@override
String get userMessage => message;
}
enum AuthFailureKind {
unauthorized, // A0401
forbidden, // 403
refreshFailed,
cryptoFailed, // 客户端加密失败(如登录密码 RSA 加密前置错)
}
/// 服务端业务失败:500/501 / 业务校验失败 / 参数错误。
class ServerFailure extends Failure {
const ServerFailure({
required super.message,
this.code,
this.statusCode,
super.cause,
super.stackTrace,
});
final String? code; // 业务错误码(如 A0500 / A0400 / A0404
final int? statusCode; // HTTP 状态码
@override
String get _typeName => 'ServerFailure';
@override
String get userMessage {
// HTTP 5xx:服务端内部错误,后端 message 可能含技术细节(SQL/堆栈),统一遮蔽
if (statusCode != null && statusCode! >= 500) return '服务器开小差了,请稍后再试';
// 业务码 / 4xxmessage 已经过 ResponseCode.friendlyMessage 映射,安全展示
return message;
}
}
/// 本地缓存/数据库失败:Drift 异常 / SQLCipher 解密失败 / 文件 IO 错误。
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.cause,
super.stackTrace,
});
@override
String get _typeName => 'CacheFailure';
@override
String get userMessage => '本地数据异常,请重启应用试试';
}
/// 兜底未知错误。
class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
super.cause,
super.stackTrace,
});
@override
String get _typeName => 'UnknownFailure';
@override
String get userMessage => '遇到了点问题,已自动上报';
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/error/exception_mapper.dart';
import 'package:sunny_mochi/core/widgets/app_toast.dart';
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
extension BuildContextX on BuildContext {
/// 弹一个 Toast 风格的 SnackBar(对应 iOS Mkt.makeToast(...))。
void showToast(
String message, {
ToastType type = ToastType.info,
Duration duration = const Duration(seconds: 4),
}) => AppToast.show(this, message, type: type, duration: duration);
/// 异常 → ExceptionMapper.fromUnknown(e, st) → toast(Failure.message)。
///
/// 兜底替代散落的 `on Object catch (e, st) { mapper.fromUnknown; if(mounted) showToast }`
/// 模板(5+ 处 mine 表单提交流程)。
void showError(WidgetRef ref, Object error, StackTrace stackTrace) {
final failure =
ref.read(exceptionMapperProvider).fromUnknown(error, stackTrace);
// 写入本地错误日志(fire-and-forget,不阻塞 UI
unawaited(ref.read(errorLoggerProvider).log(failure));
if (mounted) showToast(failure.userMessage, type: ToastType.error);
}
/// 当前 Theme 快捷读取。
ThemeData get theme => Theme.of(this);
ColorScheme get colors => theme.colorScheme;
TextTheme get textTheme => theme.textTheme;
/// 屏幕信息快捷读取。
Size get screenSize => MediaQuery.sizeOf(this);
EdgeInsets get screenPadding => MediaQuery.paddingOf(this);
double get screenWidth => screenSize.width;
double get screenHeight => screenSize.height;
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:intl/intl.dart';
extension DateTimeX on DateTime {
/// yyyy-MM-dd
String get fmtDate => DateFormat('yyyy-MM-dd').format(this);
/// yyyy-MM-dd HH:mm:ss
String get fmtDateTime => DateFormat('yyyy-MM-dd HH:mm:ss').format(this);
/// HH:mm
String get fmtHm => DateFormat('HH:mm').format(this);
/// 友好相对时间:"刚刚 / 5 分钟前 / 昨天 18:24 / 03-15 / 2024-03-15"
String get toFriendly {
final now = DateTime.now();
final diff = now.difference(this);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24 && now.day == day) {
return '今天 $fmtHm';
}
if (diff.inDays < 2 && now.day - day == 1) {
return '昨天 $fmtHm';
}
if (year == now.year) {
return DateFormat('MM-dd HH:mm').format(this);
}
return fmtDate;
}
}
extension NullableDateTimeX on DateTime? {
String get orPlaceholder => this?.fmtDateTime ?? '-';
String get orPlaceholderDate => this?.fmtDate ?? '-';
}
+25
View File
@@ -0,0 +1,25 @@
extension NumX on num {
/// 金额格式化:1234.5 → "1,234.50"
String get yuan =>
'¥${toStringAsFixed(2).replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+\.)'),
(m) => '${m[1]},',
)}';
/// 百分比:0.75 → "75%"
String get percent => '${(this * 100).toStringAsFixed(0)}%';
/// 文件大小:1024 → "1 KB"
String get bytesFmt {
if (this < 1024) return '$this B';
if (this < 1024 * 1024) return '${(this / 1024).toStringAsFixed(1)} KB';
if (this < 1024 * 1024 * 1024) {
return '${(this / 1024 / 1024).toStringAsFixed(1)} MB';
}
return '${(this / 1024 / 1024 / 1024).toStringAsFixed(1)} GB';
}
}
extension NullableNumX on num? {
String get orPlaceholder => this?.toString() ?? '-';
}
+59
View File
@@ -0,0 +1,59 @@
/// 对应 iOS BasicModule/Extension/Extension+Optional.swift。
extension NullableStringX on String? {
/// null 或空字符串时返回 "-"(对应 iOS orPlaceholder)。
/// UI 层硬约束:所有 Optional 字符串展示前必须 .orPlaceholder。
String get orPlaceholder {
if (this == null || this!.isEmpty) return '-';
return this!;
}
/// 简洁的非空判断(替代散落的 `s != null && s!.isNotEmpty` 三段式)。
bool get isNotNullOrEmpty => this != null && this!.isNotEmpty;
/// 反向:null 或空。
bool get isNullOrEmpty => this == null || this!.isEmpty;
}
extension NullableIntX on int? {
String get orPlaceholder => this?.toString() ?? '-';
}
extension NullableDoubleX on double? {
String get orPlaceholder => this?.toString() ?? '-';
}
extension StringX on String {
/// 首字母大写。
String get capitalize =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
/// 中国大陆手机号校验(对应 iOS Validator.isValidPhone)。
bool get isMobile => RegExp(r'^1[3-9]\d{9}$').hasMatch(this);
/// 身份证号校验(对应 iOS Validator.isValidIDCard)。
bool get isIdCard => RegExp(r'^\d{17}[\dXx]$').hasMatch(this);
/// Email 简易校验。
bool get isEmail => RegExp(r'^[\w.+-]+@([\w-]+\.)+[\w-]{2,}$').hasMatch(this);
/// 脱敏手机号(保留首 3 + 末 4,中间 4 个 *):13800138000 → 138****8000
String get masked {
if (length < 7) return this;
return '${substring(0, 3)}****${substring(length - 4)}';
}
/// 密码强度(0=无 / 1=弱 / 2=中 / 3=强)。
/// 规则:长度 ≥6 +1;含大小写字母 +1;含数字 +1;含特殊符号 +1(最高 3)。
int get passwordStrength {
if (length < 6) return 0;
var score = 1;
if (RegExp('[A-Z]').hasMatch(this) && RegExp('[a-z]').hasMatch(this)) {
score++;
}
if (RegExp(r'\d').hasMatch(this)) score++;
if (RegExp(r'[!@#$%^&*(),.?":{}|<>_+\-=\[\]/\\;~`]').hasMatch(this)) {
score++;
}
return score.clamp(0, 3);
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/network/response_code.dart';
part 'api_response.freezed.dart';
part 'api_response.g.dart';
@Freezed(genericArgumentFactories: true)
abstract class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse({
@JsonKey(name: 'code') required String retCode,
@Default('') @JsonKey(name: 'msg') String retMsg,
@JsonKey(name: 'data') T? retData,
}) = _ApiResponse;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object?) fromJsonT,
) => _$ApiResponseFromJson(json, fromJsonT);
}
extension ApiResponseX<T> on ApiResponse<T> {
bool get isSuccess => ResponseCode.isSuccess(retCode);
bool get isTokenExpired => ResponseCode.isUnauthorized(retCode);
T? unwrap() {
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
return retData;
}
void unwrapVoid() {
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
}
T unwrapRequired([String errorMsg = '响应数据为空']) {
final data = unwrap();
if (data == null) throw ServerFailure(message: errorMsg);
return data;
}
}
/// Datasource 层 envelope 解析入口。
///
/// 用法:`parseEnvelope(resp.data, (j) => MyModel.fromJson(j))?.unwrap()`
ApiResponse<T> parseEnvelope<T>(
Map<String, dynamic>? body,
T Function(Object?) fromJsonT, {
String emptyMsg = '响应为空',
}) {
if (body == null) throw ServerFailure(message: emptyMsg);
return ApiResponse.fromJson(body, fromJsonT);
}
+283
View File
@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'api_response.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ApiResponse<T> {
@JsonKey(name: 'code') String get retCode;@JsonKey(name: 'msg') String get retMsg;@JsonKey(name: 'data') T? get retData;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiResponseCopyWith<T, ApiResponse<T>> get copyWith => _$ApiResponseCopyWithImpl<T, ApiResponse<T>>(this as ApiResponse<T>, _$identity);
/// Serializes this ApiResponse to a JSON map.
Map<String, dynamic> toJson(Object? Function(T) toJsonT);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
@override
String toString() {
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
}
}
/// @nodoc
abstract mixin class $ApiResponseCopyWith<T,$Res> {
factory $ApiResponseCopyWith(ApiResponse<T> value, $Res Function(ApiResponse<T>) _then) = _$ApiResponseCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
});
}
/// @nodoc
class _$ApiResponseCopyWithImpl<T,$Res>
implements $ApiResponseCopyWith<T, $Res> {
_$ApiResponseCopyWithImpl(this._self, this._then);
final ApiResponse<T> _self;
final $Res Function(ApiResponse<T>) _then;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
return _then(_self.copyWith(
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
as T?,
));
}
}
/// Adds pattern-matching-related methods to [ApiResponse].
extension ApiResponsePatterns<T> on ApiResponse<T> {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ApiResponse<T> value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ApiResponse<T> value) $default,){
final _that = this;
switch (_that) {
case _ApiResponse():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ApiResponse<T> value)? $default,){
final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData) $default,) {final _that = this;
switch (_that) {
case _ApiResponse():
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,) {final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable(genericArgumentFactories: true)
class _ApiResponse<T> implements ApiResponse<T> {
const _ApiResponse({@JsonKey(name: 'code') required this.retCode, @JsonKey(name: 'msg') this.retMsg = '', @JsonKey(name: 'data') this.retData});
factory _ApiResponse.fromJson(Map<String, dynamic> json,T Function(Object?) fromJsonT) => _$ApiResponseFromJson(json,fromJsonT);
@override@JsonKey(name: 'code') final String retCode;
@override@JsonKey(name: 'msg') final String retMsg;
@override@JsonKey(name: 'data') final T? retData;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ApiResponseCopyWith<T, _ApiResponse<T>> get copyWith => __$ApiResponseCopyWithImpl<T, _ApiResponse<T>>(this, _$identity);
@override
Map<String, dynamic> toJson(Object? Function(T) toJsonT) {
return _$ApiResponseToJson<T>(this, toJsonT);
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
@override
String toString() {
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
}
}
/// @nodoc
abstract mixin class _$ApiResponseCopyWith<T,$Res> implements $ApiResponseCopyWith<T, $Res> {
factory _$ApiResponseCopyWith(_ApiResponse<T> value, $Res Function(_ApiResponse<T>) _then) = __$ApiResponseCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
});
}
/// @nodoc
class __$ApiResponseCopyWithImpl<T,$Res>
implements _$ApiResponseCopyWith<T, $Res> {
__$ApiResponseCopyWithImpl(this._self, this._then);
final _ApiResponse<T> _self;
final $Res Function(_ApiResponse<T>) _then;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
return _then(_ApiResponse<T>(
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
as T?,
));
}
}
// dart format on
+35
View File
@@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ApiResponse<T> _$ApiResponseFromJson<T>(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _ApiResponse<T>(
retCode: json['code'] as String,
retMsg: json['msg'] as String? ?? '',
retData: _$nullableGenericFromJson(json['data'], fromJsonT),
);
Map<String, dynamic> _$ApiResponseToJson<T>(
_ApiResponse<T> instance,
Object? Function(T value) toJsonT,
) => <String, dynamic>{
'code': instance.retCode,
'msg': instance.retMsg,
'data': _$nullableGenericToJson(instance.retData, toJsonT),
};
T? _$nullableGenericFromJson<T>(
Object? input,
T Function(Object? json) fromJson,
) => input == null ? null : fromJson(input);
Object? _$nullableGenericToJson<T>(
T? input,
Object? Function(T value) toJson,
) => input == null ? null : toJson(input);
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/api_config.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/network/interceptors/auth_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/auth_logout_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/cert_pinning_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/error_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/log_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/retry_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart';
import 'package:sunny_mochi/core/network/mock/dio_mock_adapter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'dio_client.g.dart';
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
@Riverpod(keepAlive: true)
Dio dioClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: ApiConfig.connectTimeout,
receiveTimeout: ApiConfig.receiveTimeout,
sendTimeout: ApiConfig.sendTimeout,
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
dio.interceptors.addAll([
CertPinningInterceptor(),
AuthInterceptor(ref),
TokenRefreshInterceptor(ref, dio),
RetryInterceptor(dio),
ErrorInterceptor(),
AuthLogoutInterceptor(ref),
buildNetworkLogInterceptor(),
]);
if (Env.useMock) {
dio.httpClientAdapter = buildDefaultMockAdapter();
}
return dio;
}
+81
View File
@@ -0,0 +1,81 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_client.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
@ProviderFor(dioClient)
final dioClientProvider = DioClientProvider._();
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
final class DioClientProvider extends $FunctionalProvider<Dio, Dio, Dio>
with $Provider<Dio> {
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
DioClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioClientHash();
@$internal
@override
$ProviderElement<Dio> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Dio create(Ref ref) {
return dioClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Dio value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Dio>(value),
);
}
}
String _$dioClientHash() => r'f740b06528c313c24f8288686585bdf379411fa2';
@@ -0,0 +1,37 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart'
show TokenRefreshInterceptor;
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
/// 自动注入 sa-token 鉴权 header。
///
/// **后端是 sa-token 框架** — header 名是动态的(登录响应 `saTokenInfo.tokenName` 返回,
/// 如 `satoken`),不是固定的 `Authorization: Bearer xxx`。
/// Token 失效(401)的处理由 [TokenRefreshInterceptor] 接管,本拦截器只管注入。
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
final Ref _ref;
/// 兜底 header 名 — 当 SecureStorage 里没有 tokenName 时使用 sa-token 框架默认值。
static const String _fallbackTokenName = 'satoken';
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final skipAuth = options.extra['skip_auth'] == true;
if (skipAuth) return handler.next(options);
final storage = _ref.read(secureStorageProvider);
final token = await storage.getToken();
if (token != null && token.isNotEmpty) {
final tokenName = await storage.getTokenName() ?? _fallbackTokenName;
options.headers[tokenName] = token;
}
handler.next(options);
}
}
@@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart';
/// 全局 AuthFailure → markLoggedOut 副作用拦截器。
///
/// 链路:
/// 1. ErrorInterceptor 已经把 [DioException] 映射为 [Failure] 写回 `e.error`
/// 2. 本拦截器(必须放在 ErrorInterceptor **之后**)检查 `e.error`
/// 3. 命中 [AuthFailure] 的 unauthorized / refreshFailed 时:
/// - 清空 SecureStorage
/// - markLoggedOut → router refreshListenable 触发 → 自动跳 /login
/// 4. forbidden 不清登录态,仅由 UI 处理
class AuthLogoutInterceptor extends Interceptor {
AuthLogoutInterceptor(this._ref);
final Ref _ref;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final failure = err.error;
if (failure is AuthFailure && _shouldLogout(failure.kind)) {
appTalker.warning(
'[AuthLogout] AuthFailure(${failure.kind.name}) → markLoggedOut + clearAll',
);
try {
await _ref.read(secureStorageProvider).clearAll();
} on Object catch (e, st) {
appTalker.warning('[AuthLogout] clearAll 失败', e, st);
}
_ref.read(authStatusProvider).markLoggedOut();
}
handler.next(err);
}
bool _shouldLogout(AuthFailureKind kind) =>
kind == AuthFailureKind.unauthorized ||
kind == AuthFailureKind.refreshFailed;
}
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// TLS 证书绑定(Pinning)拦截器。
///
/// 当前为**占位实现**[Env.pinnedFingerprints] 为空时跳过校验,
/// 非空时由 HttpClientAdapter 层做真实 SHA-256 比对。
/// 生产指纹在环境就绪后替换 Dio.httpClientAdapter 接入。
class CertPinningInterceptor extends Interceptor {
CertPinningInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (Env.pinnedFingerprints.isEmpty) {
return handler.next(options);
}
appTalker.verbose(
'[CertPinning] ${options.uri.host} → 准备校验(${Env.pinnedFingerprints.length} 指纹)',
);
handler.next(options);
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/error/exception_mapper.dart';
import 'package:sunny_mochi/core/error/failures.dart' show Failure;
/// 把 [DioException] 映射为应用层 [Failure],写回 `error.error` 字段。
///
/// 后续上层只需 catch DioException 然后读 `e.error as Failure`
/// 或者在 Repository 层 catch DioException 后调用 [ExceptionMapper.fromDio]。
class ErrorInterceptor extends Interceptor {
ErrorInterceptor({ExceptionMapper? mapper})
: _mapper = mapper ?? const ExceptionMapper();
final ExceptionMapper _mapper;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final failure = _mapper.fromDio(err);
handler.next(
err.copyWith(
error: failure,
message: failure.message,
),
);
}
}
@@ -0,0 +1,13 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
/// 网络请求日志:仅在 [Env.enableDevPanel]debug 或内测包)启用,
/// Release 包不记录请求/响应 body(含敏感数据)。
Interceptor buildNetworkLogInterceptor() {
if (!Env.enableDevPanel) {
return InterceptorsWrapper();
}
return TalkerDioLogger(talker: appTalker);
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// 408/429/500/502/503/504 指数退避重试,最多 [maxRetries] 次。
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxRetries = 3});
final Dio _dio;
final int maxRetries;
static const _retryStatuses = {408, 429, 500, 502, 503, 504};
static const Set<DioExceptionType> _retryDioTypes = {
DioExceptionType.connectionTimeout,
DioExceptionType.receiveTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.connectionError,
};
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final status = err.response?.statusCode;
final attempt = (err.requestOptions.extra['_retry_attempt'] as int?) ?? 0;
final shouldRetry =
(status != null && _retryStatuses.contains(status)) ||
_retryDioTypes.contains(err.type);
if (!shouldRetry || attempt >= maxRetries) {
return handler.next(err);
}
// 指数退避:200ms → 400ms → 800ms
final delayMs = 200 * (1 << attempt);
appTalker.info(
'[Retry] ${err.requestOptions.uri.path}${attempt + 1}/$maxRetries 次重试(${delayMs}ms 后)',
);
await Future<void>.delayed(Duration(milliseconds: delayMs));
try {
final response = await _dio.fetch<dynamic>(
err.requestOptions..extra['_retry_attempt'] = attempt + 1,
);
return handler.resolve(response);
} on DioException catch (e) {
return handler.next(e);
}
}
}
@@ -0,0 +1,141 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/network/response_code.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
/// 401 时尝试 refresh 一次 → 重放原请求;refresh 也失败才清空 token。
///
/// **互斥实现**:用 [Completer] 显式表达互斥语义,避免并发 401 重复消耗 RefreshToken
/// - 第一个 401 进入 → new Completer + 把 future 缓存在 [_refreshing]
/// - 后续并发 401 → await 同一个 [_refreshing] 的 future
/// - refresh 完成后 [_refreshing] 清空,新一轮 401 才会再次触发
///
/// **业务码分类**
/// - status==401 + body.retCode ∈ token 类码 → refresh + 重放
/// - status==401 + body.retCode 是其他业务码(如权限不足)→ 透传,不消耗 RefreshToken
/// - status==401 + 无 body 或无 retCode → 透传,不消耗 RefreshToken
class TokenRefreshInterceptor extends Interceptor {
TokenRefreshInterceptor(this._ref, this._dio);
final Ref _ref;
final Dio _dio;
Completer<bool>? _refreshing;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final status = err.response?.statusCode;
final retried = err.requestOptions.extra['_token_refreshed'] == true;
final isAuthEndpoint = err.requestOptions.extra['skip_auth'] == true;
if (status != 401 || retried || isAuthEndpoint) {
return handler.next(err);
}
final retCode = _extractRetCode(err.response?.data);
if (!ResponseCode.isTokenRefreshable(retCode)) {
err.requestOptions.extra['_auth_not_refreshable'] = true;
return handler.next(err);
}
final storage = _ref.read(secureStorageProvider);
final refreshToken = await storage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
await storage.clearAll();
return handler.next(err);
}
final pending = _refreshing;
final bool ok;
if (pending != null) {
ok = await pending.future;
} else {
final completer = Completer<bool>();
_refreshing = completer;
bool result = false;
try {
result = await _runRefresh(refreshToken);
} finally {
// 先清空 _refreshing,再 complete
// 确保新到来的 401 能进入下一轮 refresh,而当前 waiters 仍通过
// 已持有的 completer 引用得到结果(不受 _refreshing = null 影响)。
_refreshing = null;
completer.complete(result);
}
ok = result;
}
if (!ok) {
err.requestOptions.extra['_auth_refresh_failed'] = true;
return handler.next(err);
}
try {
final newToken = await storage.getToken();
if (newToken != null && newToken.isNotEmpty) {
final tokenName = await storage.getTokenName() ?? 'satoken';
err.requestOptions.headers[tokenName] = newToken;
}
final retriedResp = await _dio.fetch<dynamic>(
err.requestOptions..extra['_token_refreshed'] = true,
);
return handler.resolve(retriedResp);
} on DioException catch (e) {
return handler.next(e);
}
}
Future<bool> _runRefresh(String refreshToken) async {
try {
return await _doRefresh(refreshToken);
} on Object catch (e, st) {
appTalker.warning('[TokenRefresh] 刷新失败:$e', e, st);
await _ref.read(secureStorageProvider).clearAll();
return false;
}
}
Future<bool> _doRefresh(String refreshToken) async {
final resp = await _dio.post<Map<String, dynamic>>(
ApiPaths.authRefresh,
data: {'refresh_token': refreshToken},
options: Options(extra: {'skip_auth': true}),
);
final body = resp.data;
final retCode = body?['code'] as String? ?? body?['retCode'] as String?;
if (retCode != '00000') return false;
final retData =
(body?['data'] ?? body?['retData']) as Map<String, dynamic>?;
final access = retData?['access_token'] as String?;
final refresh = retData?['refresh_token'] as String?;
if (access == null || access.isEmpty) return false;
final storage = _ref.read(secureStorageProvider);
await storage.setToken(access);
if (refresh != null && refresh.isNotEmpty) {
await storage.setRefreshToken(refresh);
}
// tokenName 可能随 refresh 响应更新(对齐登录路径 auth_repository_impl._persistUser
final newTokenName = retData?['token_name'] as String?
?? retData?['tokenName'] as String?;
if (newTokenName != null && newTokenName.isNotEmpty) {
await storage.setTokenName(newTokenName);
}
return true;
}
String? _extractRetCode(Object? body) {
if (body is! Map) return null;
final raw = body['retCode'] ?? body['code'] ?? body['errorCode'];
return raw is String ? raw : raw?.toString();
}
}
@@ -0,0 +1,99 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/config/env.dart' show Env;
import 'package:sunny_mochi/core/network/mock/mock_response_loader.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// 把 Dio 请求拦截,根据 path + method 返回 fixtures 中的预设响应。
///
/// 启用方式:在 dio_client.dart 中检查 [Env.useMock]
/// ```dart
/// if (Env.useMock) {
/// dio.httpClientAdapter = buildDefaultMockAdapter();
/// }
/// ```
class DioMockAdapter implements HttpClientAdapter {
DioMockAdapter({this.delay = const Duration(milliseconds: 200)});
final Map<String, String> _routes = {};
final Duration delay;
bool _closed = false;
/// 注册一条 mock 路由:method+path → fixtures 文件名(不含 .json 后缀)。
void register(String method, String path, String fixtureName) {
_routes['${method.toUpperCase()} $path'] = fixtureName;
}
@override
void close({bool force = false}) {
_closed = true;
}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (_closed) {
throw DioException(requestOptions: options, message: 'adapter closed');
}
await Future<void>.delayed(delay);
final key = '${options.method} ${options.path}';
final exact = _routes[key];
var matched = exact;
if (matched == null) {
for (final entry in _routes.entries) {
if (key.startsWith(entry.key)) {
matched = entry.value;
break;
}
}
}
if (matched == null) {
appTalker.warning('[Mock] 未注册 fixture$key — 返回空 envelope');
final body = jsonEncode({
'code': '00000',
'msg': 'mock-empty',
'data': null,
});
return ResponseBody.fromString(
body,
200,
headers: {'content-type': ['application/json']},
);
}
appTalker.verbose('[Mock] $key → fixtures/$matched.json');
final json = await MockResponseLoader.load(matched);
final body = jsonEncode(json);
return ResponseBody.fromString(
body,
200,
headers: {'content-type': ['application/json']},
);
}
}
/// 默认的全局 mock 路由表。
// TODO: 按项目实际 API 路径扩展此路由表
DioMockAdapter buildDefaultMockAdapter() {
return DioMockAdapter()
// === Auth ===
..register('POST', ApiPaths.authLoginSms, 'auth/login_success')
..register('POST', ApiPaths.authLoginPwd, 'auth/login_success')
..register('POST', ApiPaths.authSmsSend, 'auth/send_sms_code_success')
..register('POST', ApiPaths.authRefresh, 'auth/refresh_success')
..register('POST', ApiPaths.crashReport, 'common/generic_success')
// === User / Mine ===
..register('GET', ApiPaths.userProfile, 'mine/profile')
..register('POST', ApiPaths.userChangePassword, 'common/generic_success')
..register('POST', ApiPaths.userAvatarUpload, 'mine/avatar_upload');
}
@@ -0,0 +1,21 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
/// 从 assets/fixtures/ 加载 JSON 响应。
///
/// 使用:
/// ```dart
/// final body = await MockResponseLoader.load('auth/login_success');
/// ```
abstract class MockResponseLoader {
static final Map<String, Map<String, dynamic>> _cache = {};
static Future<Map<String, dynamic>> load(String name) async {
if (_cache.containsKey(name)) return _cache[name]!;
final raw = await rootBundle.loadString('assets/fixtures/$name.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
_cache[name] = json;
return json;
}
}
+25
View File
@@ -0,0 +1,25 @@
sealed class NetworkException implements Exception {
const NetworkException(this.message);
final String message;
}
class NoNetworkException extends NetworkException {
const NoNetworkException() : super('网络不可用,请检查网络连接');
}
class TimeoutException extends NetworkException {
const TimeoutException() : super('请求超时,请稍后重试');
}
class ServerException extends NetworkException {
const ServerException([super.msg = '服务器异常,请稍后重试']);
}
class UnauthorizedException extends NetworkException {
const UnauthorizedException() : super('登录已过期,请重新登录');
}
class ApiException extends NetworkException {
const ApiException(super.message, this.code);
final String code;
}
+50
View File
@@ -0,0 +1,50 @@
/// 服务端业务错误码常量。
///
/// envelope 结构:`{retCode: String, retMsg: String, retData: T?}`key map: code/msg/data
/// 错误码格式:`A04XX` = 客户端错 / `A05XX` = 服务端错 / `99999` = 兜底 / `00000` = 成功
abstract class ResponseCode {
/// 成功
static const String success = '00000';
/// 兜底请求失败
static const String generic = '99999';
/// 服务端 500 类
static const String serverError = 'A0500';
static const String systemError = 'A0501';
/// 客户端 400 类
static const String paramError = 'A0400';
static const String paramMissing = 'A0402';
static const String resourceNotFound = 'A0404';
/// 鉴权过期 → 跳登录
static const String unauthorized = 'A0401';
/// Token 类业务码(占位 — 待后端确认实际值后改这里一处)。
// TODO: 后端确认后更新 tokenExpired / tokenInvalid 实际字符串值
static const String tokenExpired = 'TOKEN_EXPIRED';
static const String tokenInvalid = 'TOKEN_INVALID';
/// 仅 token 类业务码才允许 TokenRefreshInterceptor 触发 refresh。
static bool isTokenRefreshable(String? code) =>
code == unauthorized || code == tokenExpired || code == tokenInvalid;
static bool isSuccess(String? code) => code == success;
static bool isUnauthorized(String? code) => code == unauthorized;
/// 用户友好的错误提示。
static String friendlyMessage(String? code, String fallback) {
return switch (code) {
success => '',
unauthorized => '登录已过期,请重新登录',
paramError => '请求参数有误,请稍候重试',
paramMissing => '请求参数不完整,请稍候重试',
resourceNotFound => '请求的资源不存在',
serverError || systemError => '服务器开小差啦,请稍候重试',
generic => '请求失败',
_ => fallback,
};
}
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter/foundation.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// 包装 [SentryFlutter.init],统一注入:
/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底)
/// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5
/// - 屏蔽 screenshot / view hierarchy(含敏感页面)
///
/// 调用方在 main.dart 包一层:
/// ```dart
/// await SentrySetup.init(appRunner: () => runApp(...));
/// ```
class SentrySetup {
static Future<void> init({required Future<void> Function() appRunner}) async {
if (Env.sentryDsn.isEmpty) {
// P0 / 本地开发兜底:未配置 DSN 时直接跑应用,不绕道 Sentry init
if (kDebugMode) {
debugPrint('[Sentry] DSN 为空,跳过 init(仅本地,生产必须配置)');
}
await appRunner();
return;
}
await SentryFlutter.init(
(options) {
options
..dsn = Env.sentryDsn
..environment = Env.name
..tracesSampleRate = Env.isProd ? 0.2 : 1.0
..debug = kDebugMode
// === 健康数据 PII 脱敏(强约束)===
..sendDefaultPii = false
..beforeSend = scrubPii;
},
appRunner: appRunner,
);
}
/// 丢弃可能含敏感数据的字段,仅保留错误归因所需的元信息。
///
/// 脱敏覆盖:
/// - `request.data` / `request.cookies`(可能含登录密码 / token / 手机号)
/// - `request.headers` 仅保留白名单
/// - `user.email` / `user.ipAddress`(仅保留匿名 userId
/// - `breadcrumbs.message` 内 token / 手机号 / 身份证 / 长 base64 串掩盖
///
/// **可见**visible for testing — 直接调用以验证脱敏行为。
@visibleForTesting
static SentryEvent? scrubPii(SentryEvent event, Hint hint) {
// Sentry 9.x:直接 mutate 字段(copyWith 已 deprecated)。
event
..request = _scrubRequest(event.request)
..user = _scrubUser(event.user)
..breadcrumbs = _scrubBreadcrumbs(event.breadcrumbs)
// R-SEC-3 / 2026-05-10extra 字段防御性清理 —
// SDK 自动 instrumentationframe tracking 等)可能写入 Widget 名称 / 路由参数,
// 路由参数可能含手机号 / userId。用白名单过滤而非全清,保留调试上下文。
..extra = _scrubExtra(event.extra);
return event;
}
static SentryRequest? _scrubRequest(SentryRequest? request) {
if (request == null) return null;
return SentryRequest(
url: request.url,
method: request.method,
// queryString / cookies / data 强制清空(可能含 token / 手机号 / 密码 / 健康数据)
headers: _scrubHeaders(request.headers),
apiTarget: request.apiTarget,
);
}
static SentryUser? _scrubUser(SentryUser? user) {
if (user == null) return null;
// 仅保留匿名 userId(要求上层调用方已哈希);email/IP 强制清空
return SentryUser(id: user.id);
}
/// 仅保留非敏感 header(白名单策略)。
static Map<String, String>? _scrubHeaders(Map<String, String>? headers) {
if (headers == null) return null;
const allow = {'content-type', 'accept', 'user-agent', 'x-request-id'};
return {
for (final e in headers.entries)
if (allow.contains(e.key.toLowerCase())) e.key: e.value,
};
}
/// 把 breadcrumbs 中 message 内的 token / 手机号 / 身份证 / 长 base64 串掩盖。
static List<Breadcrumb>? _scrubBreadcrumbs(List<Breadcrumb>? crumbs) {
if (crumbs == null) return null;
return crumbs
.map(
(c) => Breadcrumb(
timestamp: c.timestamp,
message: c.message == null ? null : maskSensitive(c.message!),
category: c.category,
level: c.level,
type: c.type,
// data 中也可能含敏感字段,按白名单清空(仅保留 method/status_code/url 这种)
data: _scrubBreadcrumbData(c.data),
),
)
.toList();
}
static Map<String, dynamic>? _scrubBreadcrumbData(Map<String, dynamic>? data) {
if (data == null) return null;
const allow = {'method', 'status_code', 'url', 'reason'};
return {
for (final e in data.entries)
if (allow.contains(e.key))
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
};
}
/// 仅保留非敏感 extra(白名单 + 字符串值掩盖)。
///
/// 业务侧 `Sentry.captureException(extra: {...})` 应主动避免传 PII
/// 此函数作为防御层,覆盖 SDK 自动写入场景。
static Map<String, dynamic>? _scrubExtra(Map<String, dynamic>? extra) {
if (extra == null) return null;
// 路由 / Widget 上下文是调试有用且非 PII 的元信息,保留;
// 其他业务自定义字段全部清除(业务方明确知道字段含义时应在 captureException 时
// 自行 mask 后放进来,再加进 allow 列表)。
const allow = {'route', 'route_name', 'widget', 'screen', 'flavor'};
return {
for (final e in extra.entries)
if (allow.contains(e.key))
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
};
}
/// 把字符串中的常见敏感模式替换为 `***`。
///
/// 模式:
/// - 手机号:11 位 1 开头数字
/// - 身份证:18 位(最后一位可 X)
/// - JWT / 长 base64 token:连续 32+ 个 base64url 字符
/// - 形如 `Bearer xxx` / `token=xxx` 的整段
@visibleForTesting
static String maskSensitive(String input) {
return input
// Bearer / Authorization-style
.replaceAll(
RegExp(
r'(?:bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9._\-+/=]{12,}',
caseSensitive: false,
),
'***',
)
// 长 base64 / JWT
.replaceAll(RegExp(r'[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}'), '***')
// 中国大陆手机号
.replaceAll(RegExp(r'(?<!\d)1[3-9]\d{9}(?!\d)'), '***')
// 身份证号
.replaceAll(RegExp(r'(?<!\d)\d{17}[\dXx](?!\d)'), '***');
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:sunny_mochi/core/config/env.dart' show Env;
import 'package:talker_flutter/talker_flutter.dart';
/// 全局 Talker 单例,作为应用日志门面。
///
/// 用法:
/// ```dart
/// import 'package:sunny_mochi/core/observability/talker_setup.dart';
///
/// appTalker.info('hello');
/// appTalker.error('boom', e, stackTrace);
/// ```
///
/// **Release 包准入**(合规底线):[TalkerSettings.enabled] = `kDebugMode || INTERNAL_BUILD`
/// 用户线 Release 包硬关闭日志记录与设备调试面板(避免敏感数据/PII 写入设备)。
/// 详见 docs/flutter-architecture-design.md §十一.5。
final Talker appTalker = TalkerFlutter.init(
settings: TalkerSettings(
enabled: kDebugMode || Env.isInternalBuild,
),
);
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart';
import 'routes.dart';
part 'app_router.g.dart';
// 不需要登录即可访问的路径
const _publicPaths = {'/login'};
@Riverpod(keepAlive: true)
GoRouter appRouter(Ref ref) {
final auth = ref.read(authStatusProvider);
return GoRouter(
debugLogDiagnostics: true,
initialLocation: '/home',
refreshListenable: auth.listenable,
redirect: (BuildContext context, GoRouterState state) {
final loggedIn = auth.value;
final path = state.matchedLocation;
appTalker.verbose('[Router] path=$path loggedIn=$loggedIn');
if (loggedIn == null) return null; // bootstrap 未完成,保持当前
if (!loggedIn && !_publicPaths.contains(path)) return '/login';
if (loggedIn && path == '/login') return '/home';
return null;
},
routes: $appRoutes,
);
}
+51
View File
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_router.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(appRouter)
final appRouterProvider = AppRouterProvider._();
final class AppRouterProvider
extends $FunctionalProvider<GoRouter, GoRouter, GoRouter>
with $Provider<GoRouter> {
AppRouterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appRouterProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appRouterHash();
@$internal
@override
$ProviderElement<GoRouter> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
GoRouter create(Ref ref) {
return appRouter(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GoRouter value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GoRouter>(value),
);
}
}
String _$appRouterHash() => r'6f5b7a9f1595fbf5343fdaf160df95a775d77b57';
+89
View File
@@ -0,0 +1,89 @@
import 'package:go_router/go_router.dart';
import 'package:sunny_mochi/core/widgets/shell_scaffold.dart';
import 'package:flutter/material.dart';
import 'package:sunny_mochi/features/auth/presentation/pages/login_page.dart';
import 'package:sunny_mochi/features/dev_panel/presentation/pages/dev_panel_page.dart';
import 'package:sunny_mochi/features/error_report/presentation/error_report_page.dart';
part 'routes.g.dart';
// ── StatefulShellRoute(底部导航 2 Tab)─────────────────────────────────────
@TypedStatefulShellRoute<ShellRouteData>(
branches: [
TypedStatefulShellBranch<HomeBranch>(
routes: [TypedGoRoute<HomeRoute>(path: '/home')],
),
TypedStatefulShellBranch<MineBranch>(
routes: [TypedGoRoute<MineRoute>(path: '/mine')],
),
],
)
class ShellRouteData extends StatefulShellRouteData {
const ShellRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell shell,
) =>
ShellScaffold(shell: shell);
}
class HomeBranch extends StatefulShellBranchData {
const HomeBranch();
}
class MineBranch extends StatefulShellBranchData {
const MineBranch();
}
// ── Shell 叶子路由(TODO: 业务项目替换为真实页面)───────────────────────────
class HomeRoute extends GoRouteData with $HomeRoute {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const Scaffold(
body: Center(child: Text('Home — TODO: Replace with HomePage')),
);
}
class MineRoute extends GoRouteData with $MineRoute {
const MineRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const Scaffold(
body: Center(child: Text('Mine — TODO: Replace with MinePage')),
);
}
// ── 独立路由(Shell 之外)────────────────────────────────────────────────────
@TypedGoRoute<LoginRoute>(path: '/login')
class LoginRoute extends GoRouteData with $LoginRoute {
const LoginRoute();
@override
Widget build(BuildContext context, GoRouterState state) =>
const LoginPage();
}
@TypedGoRoute<DevPanelRoute>(path: '/dev')
class DevPanelRoute extends GoRouteData with $DevPanelRoute {
const DevPanelRoute();
@override
Widget build(BuildContext context, GoRouterState state) =>
const DevPanelPage();
}
@TypedGoRoute<ErrorReportRoute>(path: '/error-report')
class ErrorReportRoute extends GoRouteData with $ErrorReportRoute {
const ErrorReportRoute();
@override
Widget build(BuildContext context, GoRouterState state) =>
const ErrorReportPage();
}
+147
View File
@@ -0,0 +1,147 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'routes.dart';
// **************************************************************************
// GoRouterGenerator
// **************************************************************************
List<RouteBase> get $appRoutes => [
$shellRouteData,
$loginRoute,
$devPanelRoute,
$errorReportRoute,
];
RouteBase get $shellRouteData => StatefulShellRouteData.$route(
factory: $ShellRouteDataExtension._fromState,
branches: [
StatefulShellBranchData.$branch(
routes: [
GoRouteData.$route(path: '/home', factory: $HomeRoute._fromState),
],
),
StatefulShellBranchData.$branch(
routes: [
GoRouteData.$route(path: '/mine', factory: $MineRoute._fromState),
],
),
],
);
extension $ShellRouteDataExtension on ShellRouteData {
static ShellRouteData _fromState(GoRouterState state) =>
const ShellRouteData();
}
mixin $HomeRoute on GoRouteData {
static HomeRoute _fromState(GoRouterState state) => const HomeRoute();
@override
String get location => GoRouteData.$location('/home');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $MineRoute on GoRouteData {
static MineRoute _fromState(GoRouterState state) => const MineRoute();
@override
String get location => GoRouteData.$location('/mine');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $loginRoute =>
GoRouteData.$route(path: '/login', factory: $LoginRoute._fromState);
mixin $LoginRoute on GoRouteData {
static LoginRoute _fromState(GoRouterState state) => const LoginRoute();
@override
String get location => GoRouteData.$location('/login');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $devPanelRoute =>
GoRouteData.$route(path: '/dev', factory: $DevPanelRoute._fromState);
mixin $DevPanelRoute on GoRouteData {
static DevPanelRoute _fromState(GoRouterState state) => const DevPanelRoute();
@override
String get location => GoRouteData.$location('/dev');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $errorReportRoute => GoRouteData.$route(
path: '/error-report',
factory: $ErrorReportRoute._fromState,
);
mixin $ErrorReportRoute on GoRouteData {
static ErrorReportRoute _fromState(GoRouterState state) =>
const ErrorReportRoute();
@override
String get location => GoRouteData.$location('/error-report');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:ffi';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sunny_mochi/core/storage/db_key_provider.dart';
import 'package:sunny_mochi/core/storage/tables/error_logs_table.dart';
import 'package:sunny_mochi/core/storage/tables/users_table.dart';
import 'package:sunny_mochi/core/sync/sync_status.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖
import 'package:sqlite3/open.dart' as sqlite3_open;
// ignore: depend_on_referenced_packages — sqlite3 是 sqlcipher_flutter_libs 的传递依赖
import 'package:sqlite3/sqlite3.dart' as sqlite3_pkg;
part 'app_database.g.dart';
/// SQLCipher 库注册函数,同时用于主 Isolate 和后台 Isolate。
///
/// 必须是**顶层函数**(不能是匿名闭包)——Drift 通过 Isolate.spawn 将其传递给
/// 后台 Isolate 时走 SendPort.send 序列化路径,顶层函数引用保证可靠传递;
/// 匿名闭包在某些 Drift 2.x / Dart VM 版本组合下会静默失效(无错误,仅 setup
/// 不执行),导致后台 Isolate 继续使用系统 plain sqlite3。
void _sqlCipherIsolateSetup() {
if (kIsWeb) return;
if (Platform.isAndroid) {
sqlite3_open.open.overrideFor(
sqlite3_open.OperatingSystem.android,
openCipherOnAndroid,
);
} else if (Platform.isIOS) {
sqlite3_open.open.overrideFor(
sqlite3_open.OperatingSystem.iOS,
DynamicLibrary.process,
);
} else if (Platform.isMacOS) {
sqlite3_open.open.overrideFor(
sqlite3_open.OperatingSystem.macOS,
DynamicLibrary.process,
);
}
}
/// 应用本地加密数据库(Drift + SQLCipher AES-256)。
///
/// 脚手架简化版(schemaVersion = 1):
/// - 只包含 UsersTable 和 ErrorLogsTable
/// - 无历史迁移路径,初始 v1 schema 即为全量 schema
///
/// 启动序列:
/// 1. 注册 SQLCipher 原生库(主 Isolate + 后台 Isolate 各自注册)
/// 2. 拿到加密密钥(来自 [DbKeyProvider],详见 T-0.5
/// 3. 探测现有文件是否可用当前密钥打开;失败则删除重建
/// 4. NativeDatabase setup 时执行 `PRAGMA key = '...'` 解锁
///
/// 详见 docs/flutter-architecture-design.md §九.1.1 + §十一.5。
@DriftDatabase(tables: [Users, ErrorLogs])
class AppDatabase extends _$AppDatabase {
AppDatabase._(super.e);
/// 测试专用:内存 SQLite(不走 SQLCipher,用于纯 schema/逻辑测试)。
///
/// 生产路径必须用 [open]SQLCipher AES-256)。
@visibleForTesting
factory AppDatabase.testInMemory() => AppDatabase._(NativeDatabase.memory());
static Future<AppDatabase> open({DbKeyProvider? keyProvider}) async {
// 必须在主 Isolate 执行(内部用 MethodChannel):确保 libsqlcipher.so 已通过
// Java System.loadLibrary 加载到进程内存,后台 Isolate 的 dlopen 才能找到它。
if (!kIsWeb && Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
}
// 主 Isolate 也注册 SQLCipher override,供下方探测检查使用。
// 各 Isolate 全局状态独立,此处设置不影响后台 Isolate(由 isolateSetup 负责)。
if (!kIsWeb) _sqlCipherIsolateSetup();
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'app.db'));
final key = await (keyProvider ?? DbKeyProvider()).key;
final escaped = key.replaceAll("'", "''");
// 探测:在后台 Isolate 启动前,用当前密钥尝试打开现有文件。
// 捕获所有不可恢复情况:
// - plain SQLite 遗留文件(未加密,PRAGMA key 被忽略)
// - SQLCipher 文件但密钥不匹配(SecureStorage 重置等场景)
// - 文件损坏
// 检测失败直接删除 —— 开发阶段文件无不可恢复的用户数据。
if (!kIsWeb && file.existsSync()) {
if (!_canOpenWithKey(file.path, escaped)) {
file.deleteSync();
}
}
return AppDatabase._(
NativeDatabase.createInBackground(
file,
// 顶层函数引用(见 _sqlCipherIsolateSetup 注释)
isolateSetup: _sqlCipherIsolateSetup,
setup: (db) {
// R-SEC-1 / 2026-05-10PRAGMA key 字符串格式 — 必须做 SQL 单引号转义,
// 防止未来策略升级(PBKDF2 / 服务端下发)后 key 含 ' 字符触发注入。
//
// 注意:不切换到 SQLCipher 推荐的 raw key HEX 格式(x'...'),原因:
// 该格式要求恰好 64 个 hex 字符(32 字节 raw key),现有 RandomKeyStrategy
// 产出的 base64Url(32 bytes) ≈ 43 字符不符合,切换会破坏已加密 DB 的兼容。
// 完整迁移到 raw key 需独立设计(含数据迁移路径),见 readiness checklist。
db.execute("PRAGMA key = '$escaped';");
},
),
);
}
/// 用当前密钥同步探测文件是否可正常读取。
///
/// 在主 Isolate 执行(open() 在 runApp 之前 await),不阻塞 UI。
/// 主 Isolate 已通过 [_sqlCipherIsolateSetup] 注册 SQLCipher
/// 所以 sqlite3.open 走的是 SQLCipher,与后台 Isolate 行为一致。
static bool _canOpenWithKey(String path, String escapedKey) {
try {
final probe = sqlite3_pkg.sqlite3.open(path);
try {
probe
..execute("PRAGMA key = '$escapedKey';")
..select('PRAGMA user_version;');
return true;
} finally {
probe.dispose();
}
} on Exception catch (_) {
return false;
}
}
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
);
}
/// 全局 AppDatabase provider。在 main.dart 通过 override 注入实例。
@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) => throw UnimplementedError(
'appDatabaseProvider must be overridden in main.dart',
);
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
import 'package:drift/drift.dart';
import 'package:sunny_mochi/core/storage/app_database.dart';
import 'package:sunny_mochi/core/storage/tables/users_table.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'users_dao.g.dart';
@DriftAccessor(tables: [Users])
class UsersDao extends DatabaseAccessor<AppDatabase> with _$UsersDaoMixin {
UsersDao(super.attachedDatabase);
Future<UserRow?> getById(String userId) =>
(select(users)..where((u) => u.userId.equals(userId))).getSingleOrNull();
Stream<UserRow?> watchById(String userId) => (select(
users,
)..where((u) => u.userId.equals(userId))).watchSingleOrNull();
Future<void> upsert(UsersCompanion user) =>
into(users).insertOnConflictUpdate(user);
Future<int> deleteById(String userId) =>
(delete(users)..where((u) => u.userId.equals(userId))).go();
}
@Riverpod(keepAlive: true)
UsersDao usersDao(Ref ref) => UsersDao(ref.watch(appDatabaseProvider));
+64
View File
@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'users_dao.dart';
// ignore_for_file: type=lint
mixin _$UsersDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
UsersDaoManager get managers => UsersDaoManager(this);
}
class UsersDaoManager {
final _$UsersDaoMixin _db;
UsersDaoManager(this._db);
$$UsersTableTableManager get users =>
$$UsersTableTableManager(_db.attachedDatabase, _db.users);
}
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(usersDao)
final usersDaoProvider = UsersDaoProvider._();
final class UsersDaoProvider
extends $FunctionalProvider<UsersDao, UsersDao, UsersDao>
with $Provider<UsersDao> {
UsersDaoProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'usersDaoProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$usersDaoHash();
@$internal
@override
$ProviderElement<UsersDao> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
UsersDao create(Ref ref) {
return usersDao(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(UsersDao value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<UsersDao>(value),
);
}
}
String _$usersDaoHash() => r'209c5286cb20f72750a77a007a5c04c7ef22bfea';
+60
View File
@@ -0,0 +1,60 @@
// ignore_for_file: one_member_abstracts — 是策略接口(PBKDF2 / 服务端下发等替换实现 P3 落地)
import 'dart:convert';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// SQLCipher 数据库主密钥派生策略。
///
/// 当前实现:`RandomKeyStrategy` — 首次启动随机生成 32 字节,
/// 通过 [FlutterSecureStorage]iOS Keychain / Android EncryptedSharedPreferences)持久化。
/// 用户无感知,体验最佳;用户换设备会丢失本地数据(云端备份不影响)。
///
/// 备选策略(待 Q7 答复决议,仅替换 [DbKeyProvider.strategy] 即可,调用方零改动):
/// - 用户密码派生(PBKDF2):强合规,改密复杂
/// - 服务端下发:可主动撤销,离线不可解锁
/// - 两段式(A+B 组合):最复杂
///
/// 详见 docs/flutter-architecture-design.md §九.1.2 与 §十一.5。
abstract class KeyDerivationStrategy {
Future<String> deriveKey();
}
class RandomKeyStrategy implements KeyDerivationStrategy {
RandomKeyStrategy({FlutterSecureStorage? storage})
: _storage = storage ?? _defaultStorage;
static const _storageKey = 'db_master_key_v1';
static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final FlutterSecureStorage _storage;
@override
Future<String> deriveKey() async {
final existing = await _storage.read(key: _storageKey);
if (existing != null && existing.isNotEmpty) return existing;
final rng = Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
final key = base64Url.encode(bytes);
await _storage.write(key: _storageKey, value: key);
return key;
}
}
class DbKeyProvider {
DbKeyProvider({KeyDerivationStrategy? strategy})
: _strategy = strategy ?? RandomKeyStrategy();
final KeyDerivationStrategy _strategy;
String? _cached;
Future<String> get key async {
return _cached ??= await _strategy.deriveKey();
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Token / 用户标识等敏感数据的本地持久化(对应 iOS UserManager.shared.token)。
///
/// **DI 注入(R-TEST-1 / 2026-05-10**:构造函数接受可选 [FlutterSecureStorage]
/// 测试时可注入 in-memory 替代实现,避开真实 Keychain / EncryptedSharedPreferences。
/// 生产代码通过 [secureStorageProvider] 获取实例,**禁止再使用 [SecureStorage.instance]**。
class SecureStorage {
SecureStorage({FlutterSecureStorage? storage})
: _storage = storage ?? _defaultStorage;
/// 兼容旧调用点的静态实例(与 provider 默认值保持一致)。
/// 新代码应通过 [secureStorageProvider] 注入。
static final instance = SecureStorage();
static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
// 与 db_key_provider.dart 一致:解锁后台可读,避免锁屏后 token 不可用
accessibility: KeychainAccessibility.first_unlock,
),
);
final FlutterSecureStorage _storage;
static const _keyToken = 'auth_token';
/// sa-token 动态 header 名(对齐 iOS UserManager.tokenNameKey)。
static const _keyTokenName = 'auth_token_name';
static const _keyRefreshToken = 'refresh_token';
static const _keyUserId = 'user_id';
Future<String?> getToken() => _storage.read(key: _keyToken);
Future<void> setToken(String token) =>
_storage.write(key: _keyToken, value: token);
/// sa-token 动态 header 名(如 `satoken`)— 必须从登录响应取,不能硬编码。
Future<String?> getTokenName() => _storage.read(key: _keyTokenName);
Future<void> setTokenName(String name) =>
_storage.write(key: _keyTokenName, value: name);
Future<String?> getRefreshToken() => _storage.read(key: _keyRefreshToken);
Future<void> setRefreshToken(String token) =>
_storage.write(key: _keyRefreshToken, value: token);
Future<String?> getUserId() => _storage.read(key: _keyUserId);
Future<void> setUserId(String id) =>
_storage.write(key: _keyUserId, value: id);
Future<void> clearAll() => _storage.deleteAll();
}
/// SecureStorage 全局 provider — storage DI 的单一定义点。
///
/// 构造注入规则(R-ARCH-2):所有需要访问安全存储的类(拦截器/Repository/Provider
/// 必须通过此 provider 获取,禁止直接引用 [SecureStorage.instance] 静态单例。
final secureStorageProvider = Provider<SecureStorage>(
(ref) => SecureStorage.instance,
);
@@ -0,0 +1,31 @@
import 'package:drift/drift.dart';
/// 本地错误日志表(keep last 200)。
///
/// [kind] — 错误分类(ErrorKind.name),前端展示用
/// [message] — 技术细节,供后端追踪(不展示给用户)
/// [displayMessage] — 用户看到的友好文案(Failure.userMessage
/// [code] — 业务错误码(ServerFailure.code,如 A0500
/// [statusCode] — HTTP 状态码(ServerFailure.statusCode
/// [reported] — 是否已通过"错误报告"功能上报过
///
/// 设备信息(v5 新增,nullable 兼容迁移):
/// [deviceModel] / [osVersion] / [appVersion] / [appBuild]
/// 在 log() 时快照,确保上报的是**错误发生时**的设备上下文,而非发送时。
@DataClassName('ErrorLogRow')
class ErrorLogs extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get kind => text()();
TextColumn get message => text()();
TextColumn get displayMessage => text()();
TextColumn get code => text().nullable()();
IntColumn get statusCode => integer().nullable()();
DateTimeColumn get occurredAt => dateTime()();
BoolColumn get reported =>
boolean().withDefault(const Constant(false))();
// v5: 设备上下文(事发时快照)
TextColumn get deviceModel => text().nullable()();
TextColumn get osVersion => text().nullable()();
TextColumn get appVersion => text().nullable()();
TextColumn get appBuild => text().nullable()();
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
import 'package:sunny_mochi/core/sync/sync_status.dart';
/// 所有需要 Offline-First 同步的 Drift 表必须 mixin 这组 4 字段。
///
/// - [syncStatus] 当前状态([SyncStatus]
/// - [localUpdatedAt] 本地最后修改时间(用于决定 dirty)
/// - [serverUpdatedAt] 服务端最后修改时间(last-write-wins 比较基准)
/// - [conflictPayload] 冲突时备份的服务端版本 JSON(人工或策略恢复)
///
/// 详见 docs/flutter-architecture-design.md §五.21。
mixin SyncColumns on Table {
IntColumn get syncStatus =>
intEnum<SyncStatus>().withDefault(const Constant(0))();
DateTimeColumn get localUpdatedAt =>
dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get serverUpdatedAt => dateTime().nullable()();
TextColumn get conflictPayload => text().nullable()();
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:drift/drift.dart';
import 'package:sunny_mochi/core/storage/tables/sync_columns.dart';
/// 用户本地缓存表(对应 iOS UserManager.currentUser 持久化)。
///
/// token 不存 DB — 由 SecureStorage 独占(Keychain / EncryptedSharedPreferences);
/// refreshToken 镜像存入 DB 仅用于可观测性,TokenRefreshInterceptor 仍读 SecureStorage。
@DataClassName('UserRow')
class Users extends Table with SyncColumns {
TextColumn get userId => text()();
TextColumn get username => text().nullable()();
TextColumn get realName => text().nullable()();
TextColumn get phone => text().nullable()();
TextColumn get avatar => text().nullable()();
IntColumn get gender => integer().nullable()();
IntColumn get age => integer().nullable()();
TextColumn get refreshToken => text().nullable()();
TextColumn get email => text().nullable()();
DateTimeColumn get birthday => dateTime().nullable()();
TextColumn get employeeNo => text().nullable()();
TextColumn get company => text().nullable()();
TextColumn get department => text().nullable()();
@override
Set<Column<Object>> get primaryKey => {userId};
}
+38
View File
@@ -0,0 +1,38 @@
// ignore_for_file: one_member_abstracts — Strategy 接口,多种实现(ServerWins/LastWriteWins/Custom
/// 冲突解决策略:服务端响应与本地版本不一致时如何决策。
///
/// v1.2 默认策略:`LastWriteWinsResolver`(按 serverUpdatedAt 比较时间戳)。
/// 业务场景需要乐观锁 / 三方合并的,单独实现 `ConflictResolver` 子类。
abstract class ConflictResolver<T> {
/// 返回解决后的最终值(由调用方写回 DB)。
T resolve({required T local, required T server});
}
/// 默认实现:服务端权威 — 本地直接被服务端覆盖。
/// 适合健康数据采集这种"服务端是 source-of-truth"的场景。
class ServerWinsResolver<T> implements ConflictResolver<T> {
const ServerWinsResolver();
@override
T resolve({required T local, required T server}) => server;
}
/// last-write-wins by timestamp。
/// 调用方传入 timestamp 比较函数(avoid 包装 generic)。
class LastWriteWinsResolver<T> implements ConflictResolver<T> {
const LastWriteWinsResolver({
required this.localTimestamp,
required this.serverTimestamp,
});
final DateTime Function(T value) localTimestamp;
final DateTime Function(T value) serverTimestamp;
@override
T resolve({required T local, required T server}) {
return serverTimestamp(server).isAfter(localTimestamp(local))
? server
: local;
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/core/sync/sync_status.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sync_service.g.dart';
/// 单条 dirty 记录的同步执行器。各业务模块实现此接口注册到 SyncService。
abstract class SyncTask {
/// 唯一标识(用于日志和去重)
String get key;
/// 执行同步:成功返回 true,失败返回 false(保留 pending 等下轮)。
/// 冲突时由实现方写入 conflictPayload 并返回 false。
Future<bool> execute();
}
/// Offline-First 后台同步调度器。
///
/// - 监听 [Connectivity] 网络状态变化,恢复联网时触发一轮同步
/// - 业务层通过 [enqueue] 注册 SyncTask
/// - 串行执行(避免并发污染服务端)
///
/// 详见 docs/flutter-architecture-design.md §五.21。
class SyncService {
SyncService({
required Connectivity connectivity,
}) : _connectivity = connectivity;
final Connectivity _connectivity;
final List<SyncTask> _queue = [];
StreamSubscription<List<ConnectivityResult>>? _connSub;
bool _running = false;
/// 启动后台监听。在 main.dart 完成 ProviderScope 初始化后调用。
///
/// **R-ROB-1 / 2026-05-10**:幂等 — 重复调用会先 cancel 旧 subscription
/// 防止 hot reload / Widget 重建时累积订阅造成 _drain 多次触发。
Future<void> start() async {
await _connSub?.cancel();
_connSub = _connectivity.onConnectivityChanged.listen(_onConnectivity);
final initial = await _connectivity.checkConnectivity();
if (_isOnline(initial)) {
unawaited(_drain());
}
}
Future<void> dispose() async {
await _connSub?.cancel();
_connSub = null;
}
/// 业务层注册一个 dirty 任务(同步后由任务自身从队列移除)。
void enqueue(SyncTask task) {
_queue.add(task);
appTalker.verbose('[Sync] enqueue ${task.key} (queue=${_queue.length})');
unawaited(_drain());
}
Future<void> _drain() async {
if (_running || _queue.isEmpty) return;
_running = true;
try {
while (_queue.isNotEmpty) {
final task = _queue.removeAt(0);
try {
final ok = await task.execute();
appTalker.info(
'[Sync] ${task.key} ${ok ? "" : "✗ (status=${SyncStatus.conflict.name} 待解决)"}',
);
} on Object catch (e, st) {
appTalker.warning('[Sync] ${task.key} 异常:$e', e, st);
// 失败任务不重新入队(保持 dirty 在 DB 等下轮启动)
}
}
} finally {
_running = false;
}
}
void _onConnectivity(List<ConnectivityResult> results) {
if (_isOnline(results)) {
appTalker.info('[Sync] 检测到联网,触发 drain');
unawaited(_drain());
}
}
bool _isOnline(List<ConnectivityResult> results) =>
results.any((r) => r != ConnectivityResult.none);
}
@Riverpod(keepAlive: true)
Connectivity connectivity(Ref ref) => Connectivity();
@Riverpod(keepAlive: true)
SyncService syncService(Ref ref) {
final svc = SyncService(connectivity: ref.watch(connectivityProvider));
ref.onDispose(() => unawaited(svc.dispose()));
return svc;
}
+92
View File
@@ -0,0 +1,92 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(connectivity)
final connectivityProvider = ConnectivityProvider._();
final class ConnectivityProvider
extends $FunctionalProvider<Connectivity, Connectivity, Connectivity>
with $Provider<Connectivity> {
ConnectivityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectivityProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectivityHash();
@$internal
@override
$ProviderElement<Connectivity> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Connectivity create(Ref ref) {
return connectivity(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Connectivity value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Connectivity>(value),
);
}
}
String _$connectivityHash() => r'e66720f09edf1a8b09e450e1eaedd51da9443f0e';
@ProviderFor(syncService)
final syncServiceProvider = SyncServiceProvider._();
final class SyncServiceProvider
extends $FunctionalProvider<SyncService, SyncService, SyncService>
with $Provider<SyncService> {
SyncServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncServiceHash();
@$internal
@override
$ProviderElement<SyncService> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
SyncService create(Ref ref) {
return syncService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SyncService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SyncService>(value),
);
}
}
String _$syncServiceHash() => r'6a8c53aab166422156d95e8cd712592195946e99';
+14
View File
@@ -0,0 +1,14 @@
/// 单条业务记录的同步状态机。Drift 表用 SyncColumns mixin 4 字段约定记录此状态。
enum SyncStatus {
/// 本地新增/修改,待同步到服务端
pending,
/// 正在同步中(避免并发重复发送)
syncing,
/// 已成功同步到服务端
synced,
/// 服务端拒绝/冲突,需要人工或策略解决
conflict,
}
+200
View File
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
/// 5 套主题色板(white 为 Flutter 新增默认主题,其余 4 套与 iOS plist 1:1 对齐)。
///
/// Flutter 默认 `white`(白色导航栏 + 中性灰背景 + 紫色点缀,对应设计稿默认配色)。
/// iOS 默认 `purple`AppThemeManager.swift:44 `?? "purple"`)— 两端可独立配置。
enum AppColorScheme { white, blue, red, green, purple }
/// 单套主题色板 — 对应一个 plist 文件的 10 个色值 key。
///
/// 命名与 iOS ThemeKey 1:1 对应;iOS 用 `view.theme_backgroundColor = ThemeKey.backgroundColor` 绑定,
/// Flutter 通过 [Theme.of(context).extension<AppPalette>()] 读取。
@immutable
class AppPalette extends ThemeExtension<AppPalette> {
const AppPalette({
required this.primary,
required this.secondary,
required this.background,
required this.text,
required this.navBar,
required this.navBarText,
required this.buttonBg,
required this.buttonText,
required this.tabBarSelected,
required this.tabBarNormal,
});
final Color primary;
final Color secondary;
final Color background;
final Color text;
final Color navBar;
final Color navBarText;
final Color buttonBg;
final Color buttonText;
final Color tabBarSelected;
final Color tabBarNormal;
@override
AppPalette copyWith({
Color? primary,
Color? secondary,
Color? background,
Color? text,
Color? navBar,
Color? navBarText,
Color? buttonBg,
Color? buttonText,
Color? tabBarSelected,
Color? tabBarNormal,
}) {
return AppPalette(
primary: primary ?? this.primary,
secondary: secondary ?? this.secondary,
background: background ?? this.background,
text: text ?? this.text,
navBar: navBar ?? this.navBar,
navBarText: navBarText ?? this.navBarText,
buttonBg: buttonBg ?? this.buttonBg,
buttonText: buttonText ?? this.buttonText,
tabBarSelected: tabBarSelected ?? this.tabBarSelected,
tabBarNormal: tabBarNormal ?? this.tabBarNormal,
);
}
@override
AppPalette lerp(ThemeExtension<AppPalette>? other, double t) {
if (other is! AppPalette) return this;
return AppPalette(
primary: Color.lerp(primary, other.primary, t)!,
secondary: Color.lerp(secondary, other.secondary, t)!,
background: Color.lerp(background, other.background, t)!,
text: Color.lerp(text, other.text, t)!,
navBar: Color.lerp(navBar, other.navBar, t)!,
navBarText: Color.lerp(navBarText, other.navBarText, t)!,
buttonBg: Color.lerp(buttonBg, other.buttonBg, t)!,
buttonText: Color.lerp(buttonText, other.buttonText, t)!,
tabBarSelected: Color.lerp(tabBarSelected, other.tabBarSelected, t)!,
tabBarNormal: Color.lerp(tabBarNormal, other.tabBarNormal, t)!,
);
}
}
/// 5 套色板 — white 为 Flutter 默认主题,其余 4 套与 iOS plist 完全一致。
abstract class AppPalettes {
/// 白色默认主题:白色导航栏 + 深色文字 + 中性灰背景 + 紫色点缀。
/// 对应设计稿默认白灰配色(无色调染色)。
///
/// 字色 #252535 与设计稿其他地方一致(手机号/列表项/标题统一字色)。
static const AppPalette white = AppPalette(
primary: Color(0xFF947DFF),
secondary: Color(0xFFA893FF),
background: Color(0xFFF5F6F8),
text: Color(0xFF252535),
navBar: Color(0xFFFFFFFF),
navBarText: Color(0xFF252535),
buttonBg: Color(0xFF947DFF),
buttonText: Color(0xFFFFFFFF),
tabBarSelected: Color(0xFF947DFF),
tabBarNormal: Color(0xFF77849E),
);
static const AppPalette blue = AppPalette(
primary: Color(0xFF3366FF),
secondary: Color(0xFF4D7FFF),
background: Color(0xFFF0F4FF),
text: Color(0xFF333333),
navBar: Color(0xFF3366FF),
navBarText: Color(0xFFFFFFFF),
buttonBg: Color(0xFF3366FF),
buttonText: Color(0xFFFFFFFF),
tabBarSelected: Color(0xFF3366FF),
tabBarNormal: Color(0xFF999999),
);
static const AppPalette red = AppPalette(
primary: Color(0xFFE63333),
secondary: Color(0xFFFF4D4D),
background: Color(0xFFFFF5F5),
text: Color(0xFF333333),
navBar: Color(0xFFE63333),
navBarText: Color(0xFFFFFFFF),
buttonBg: Color(0xFFE63333),
buttonText: Color(0xFFFFFFFF),
tabBarSelected: Color(0xFFE63333),
tabBarNormal: Color(0xFF999999),
);
static const AppPalette green = AppPalette(
primary: Color(0xFF33A855),
secondary: Color(0xFF4DC46A),
background: Color(0xFFF0FFF4),
text: Color(0xFF333333),
navBar: Color(0xFF33A855),
navBarText: Color(0xFFFFFFFF),
buttonBg: Color(0xFF33A855),
buttonText: Color(0xFFFFFFFF),
tabBarSelected: Color(0xFF33A855),
tabBarNormal: Color(0xFF999999),
);
static const AppPalette purple = AppPalette(
primary: Color(0xFF947DFF),
secondary: Color(0xFF947DFF),
background: Color(0xFFF8F0FF),
text: Color(0xFF333333),
navBar: Color(0xFF947DFF),
navBarText: Color(0xFFFFFFFF),
buttonBg: Color(0xFF947DFF),
buttonText: Color(0xFFFFFFFF),
tabBarSelected: Color(0xFF947DFF),
tabBarNormal: Color(0xFF999999),
);
static const Map<AppColorScheme, AppPalette> all = {
AppColorScheme.white: white,
AppColorScheme.blue: blue,
AppColorScheme.red: red,
AppColorScheme.green: green,
AppColorScheme.purple: purple,
};
static AppPalette of(AppColorScheme scheme) => all[scheme]!;
/// 中文展示名(设置页主题切换 UI 用)
static String displayName(AppColorScheme scheme) => switch (scheme) {
AppColorScheme.white => '默认',
AppColorScheme.blue => '蓝色',
AppColorScheme.red => '红色',
AppColorScheme.green => '绿色',
AppColorScheme.purple => '紫色',
};
}
/// 通用色(与主题无关,跨 4 套主题不变) — 文字 / 背景 / 状态色。
abstract class AppColors {
// 4 套主题色映射(向后兼容旧调用,新代码请用 AppPalettes.of
static Map<AppColorScheme, Color> get primary => {
for (final e in AppPalettes.all.entries) e.key: e.value.primary,
};
// 通用色彩(4 套主题共用)
static const Color surface = Color(0xFFFFFFFF);
static const Color divider = Color(0xFFEEEEEE);
static const Color textPrimary = Color(0xFF333333);
static const Color textSecondary = Color(0xFF666666);
static const Color textHint = Color(0xFF999999);
static const Color textPlaceholder = Color(0xFFBBBBBB);
static const Color error = Color(0xFFFF4D4F);
static const Color warning = Color(0xFFFA8C16);
static const Color success = Color(0xFF52C41A);
// 兼容旧引用(这些常量已退化为 fallback / 跨主题中性色)
static const Color background = Color(0xFFF5F5F5);
static const Color navBar = Color(0xFFFFFFFF);
static const Color navBarDark = Color(0xFF1A1A1A);
static const Color tabBarUnselected = Color(0xFF999999);
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:sunny_mochi/core/theme/app_colors.dart';
/// 应用主题工厂 — 按 [AppColorScheme] 真实切换全部色(对齐 iOS 4 套 plist)。
///
/// 与旧版差异:
/// - 旧版只把 primary 当作 seednav/btn/bg 都写死,导致 4 套主题视觉差异不明显
/// - 新版从 [AppPalettes] 读完整 10 色,AppBar/ColorScheme/extensions 全部按主题切
/// - 通过 ThemeExtension 暴露 [AppPalette],业务代码 `Theme.of(context).extension<AppPalette>()!.buttonBg` 读
abstract class AppTheme {
static ThemeData light({AppColorScheme scheme = AppColorScheme.white}) {
final palette = AppPalettes.of(scheme);
return ThemeData(
useMaterial3: true,
// BackButton 图标:iOS 平台渲染 arrow_back_ios_new< 样式),与登录页一致。
// ScrollBehavior 使用 defaultTargetPlatform,不受此字段影响。
platform: TargetPlatform.iOS,
// 全局字体 — HarmonyOS Sans SCGB2312 子集,pubspec 注册)
fontFamily: 'HarmonyOS Sans SC',
colorScheme: ColorScheme.fromSeed(
seedColor: palette.primary,
primary: palette.primary,
secondary: palette.secondary,
surface: AppColors.surface,
error: AppColors.error,
),
scaffoldBackgroundColor: palette.background,
primaryColor: palette.primary,
appBarTheme: AppBarTheme(
backgroundColor: palette.navBar,
foregroundColor: palette.navBarText,
elevation: 0,
// Material 3 默认会在滚动时给 AppBar 叠加 surfaceTint 染色(紫色);
// 设计稿要求 navBar 保持纯白,必须显式置为 transparent + 0 elevation。
surfaceTintColor: Colors.transparent,
scrolledUnderElevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: palette.navBarText,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
iconTheme: IconThemeData(color: palette.navBarText, size: 22.sp),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: palette.buttonBg,
foregroundColor: palette.buttonText,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: palette.buttonBg,
foregroundColor: palette.buttonText,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: palette.primary),
),
tabBarTheme: TabBarThemeData(
labelColor: palette.tabBarSelected,
unselectedLabelColor: palette.tabBarNormal,
indicatorColor: palette.tabBarSelected,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
selectedItemColor: palette.tabBarSelected,
unselectedItemColor: palette.tabBarNormal,
),
textTheme: TextTheme(
bodyLarge: TextStyle(fontSize: 16.sp, color: palette.text),
bodyMedium: TextStyle(fontSize: 14.sp, color: AppColors.textSecondary),
bodySmall: TextStyle(fontSize: 12.sp, color: AppColors.textHint),
titleLarge: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: palette.text,
),
),
dividerColor: AppColors.divider,
dividerTheme: const DividerThemeData(space: 1, thickness: 1),
extensions: [palette],
);
}
static ThemeData dark({AppColorScheme scheme = AppColorScheme.white}) {
final palette = AppPalettes.of(scheme);
return ThemeData(
useMaterial3: true,
platform: TargetPlatform.iOS,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: palette.primary,
primary: palette.primary,
secondary: palette.secondary,
brightness: Brightness.dark,
),
primaryColor: palette.primary,
scaffoldBackgroundColor: const Color(0xFF121212),
appBarTheme: AppBarTheme(
backgroundColor: AppColors.navBarDark,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 17.sp,
fontWeight: FontWeight.w600,
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
selectedItemColor: palette.tabBarSelected,
unselectedItemColor: palette.tabBarNormal,
),
extensions: [palette],
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:sunny_mochi/core/theme/app_colors.dart';
/// 主题化图片资源分发工具。
///
/// 约定:`assets/themes/{scheme}/{name}.png`,每套主题放同名文件。
/// 调用:`Image.asset(ThemeAssets.tabHomeNormal(scheme))`
///
/// TODO: 业务项目按需扩展此类,添加与品牌设计对应的主题图片路径。
/// 若 TabBar 使用 Material Icons(当前脚手架默认),此类可不使用。
abstract class ThemeAssets {
static const String _root = 'assets/themes';
static String _dir(AppColorScheme scheme) => '$_root/${scheme.name}';
// ═══ Tab 图标(脚手架默认 2 Tab,业务项目按需扩展)══════════════════════
/// Home Tab
static String tabHomeNormal(AppColorScheme scheme) =>
'${_dir(scheme)}/tab_home_normal.png';
static String tabHomeSelected(AppColorScheme scheme) =>
'${_dir(scheme)}/tab_home_selected.png';
/// Mine Tab
static String tabMineNormal(AppColorScheme scheme) =>
'${_dir(scheme)}/tab_mine_normal.png';
static String tabMineSelected(AppColorScheme scheme) =>
'${_dir(scheme)}/tab_mine_selected.png';
// ═══ 业务图片占位(TODO: 填入项目实际图片路径)════════════════════════════
// static String heroBanner(AppColorScheme scheme) =>
// '${_dir(scheme)}/hero_banner.png';
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sunny_mochi/core/theme/app_colors.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'theme_notifier.g.dart';
/// 当前色板 Notifier — 对应 iOS AppThemeManager。
///
/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。
/// 切换持久化到 SharedPreferences,下次启动恢复。
@Riverpod(keepAlive: true)
class ThemeNotifier extends _$ThemeNotifier {
static const _keyScheme = 'color_scheme';
@override
AppColorScheme build() {
unawaited(_restore());
return AppColorScheme.white;
}
/// 切换主题色板(同时持久化)
void switchScheme(AppColorScheme scheme) {
state = scheme;
unawaited(
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(_keyScheme, scheme.name),
),
);
}
Future<void> _restore() async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(_keyScheme);
if (name != null) {
state = AppColorScheme.values.firstWhere(
(e) => e.name == name,
orElse: () => AppColorScheme.white,
);
}
}
}
/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐)
@Riverpod(keepAlive: true)
class ThemeModeNotifier extends _$ThemeModeNotifier {
static const _keyMode = 'theme_mode';
@override
ThemeMode build() {
unawaited(_restore());
return ThemeMode.light;
}
void switchMode(ThemeMode mode) {
state = mode;
unawaited(
SharedPreferences.getInstance().then(
(prefs) => prefs.setString(_keyMode, mode.name),
),
);
}
Future<void> _restore() async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(_keyMode);
if (name != null) {
state = ThemeMode.values.firstWhere(
(e) => e.name == name,
orElse: () => ThemeMode.light,
);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// 当前色板 Notifier — 对应 iOS AppThemeManager。
///
/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。
/// 切换持久化到 SharedPreferences,下次启动恢复。
@ProviderFor(ThemeNotifier)
final themeProvider = ThemeNotifierProvider._();
/// 当前色板 Notifier — 对应 iOS AppThemeManager。
///
/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。
/// 切换持久化到 SharedPreferences,下次启动恢复。
final class ThemeNotifierProvider
extends $NotifierProvider<ThemeNotifier, AppColorScheme> {
/// 当前色板 Notifier — 对应 iOS AppThemeManager。
///
/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。
/// 切换持久化到 SharedPreferences,下次启动恢复。
ThemeNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeNotifierHash();
@$internal
@override
ThemeNotifier create() => ThemeNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppColorScheme value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppColorScheme>(value),
);
}
}
String _$themeNotifierHash() => r'e4ed9671d872b5f592f2e5c732f355cf2efa217a';
/// 当前色板 Notifier — 对应 iOS AppThemeManager。
///
/// **默认 white**(白色导航栏 + 中性灰背景,对应设计稿默认配色)。
/// 切换持久化到 SharedPreferences,下次启动恢复。
abstract class _$ThemeNotifier extends $Notifier<AppColorScheme> {
AppColorScheme build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AppColorScheme, AppColorScheme>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppColorScheme, AppColorScheme>,
AppColorScheme,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐)
@ProviderFor(ThemeModeNotifier)
final themeModeProvider = ThemeModeNotifierProvider._();
/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐)
final class ThemeModeNotifierProvider
extends $NotifierProvider<ThemeModeNotifier, ThemeMode> {
/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐)
ThemeModeNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeModeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeModeNotifierHash();
@$internal
@override
ThemeModeNotifier create() => ThemeModeNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeMode>(value),
);
}
}
String _$themeModeNotifierHash() => r'daa7db2830c3897ea7d834261955b00e35431e4c';
/// 明亮 / 暗色 / 跟随系统 模式(与 iOS UIInterfaceStyle 对齐)
abstract class _$ThemeModeNotifier extends $Notifier<ThemeMode> {
ThemeMode build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<ThemeMode, ThemeMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ThemeMode, ThemeMode>,
ThemeMode,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
+30
View File
@@ -0,0 +1,30 @@
/// 密码策略校验(12 位 + 大小写 + 数字 + 特殊符号)
///
/// 对齐设计稿 #3/#5 要求:"密码不能少于12位,必须包含大小写字母、数字、特殊符号。"
String? validatePasswordPolicy(String pwd) {
if (pwd.length < 12) return '密码不能少于12位';
if (!RegExp('[A-Z]').hasMatch(pwd)) return '需包含大写字母';
if (!RegExp('[a-z]').hasMatch(pwd)) return '需包含小写字母';
if (!RegExp('[0-9]').hasMatch(pwd)) return '需包含数字';
if (!RegExp(r'[!@#$%^&*()\-_=+\[\]{};:,.<>?/|\\]').hasMatch(pwd)) {
return '需包含特殊符号';
}
return null;
}
/// 密码强度评分(用于 StrengthIndicator 重映射)
///
/// 返回 0-30=不合格 / 1=弱 / 2=中 / 3=强(满足全部要求)
int passwordStrengthLevel(String pwd) {
if (pwd.length < 12) return 0;
var score = 0;
if (RegExp('[A-Z]').hasMatch(pwd)) score++;
if (RegExp('[a-z]').hasMatch(pwd)) score++;
if (RegExp('[0-9]').hasMatch(pwd)) score++;
if (RegExp(r'[!@#$%^&*()\-_=+\[\]{};:,.<>?/|\\]').hasMatch(pwd)) score++;
return switch (score) {
0 || 1 => 1,
2 || 3 => 2,
_ => 3,
};
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
/// 短信验证码倒计时 mixin — 抽自 phone_add / old_phone_verify 重复模板。
///
/// 用法(任意 [State] 子类,包含 ConsumerState):
/// ```dart
/// class _MyPageState extends ConsumerState<MyPage>
/// with SmsCountdownMixin<MyPage> {
///
/// Future<void> _sendCode() async {
/// await repo.sendSms(phone);
/// startSmsCountdown(); // 默认 60s
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// final text = isSmsCountingDown ? '${smsCountdown}s 后重发' : '发送验证码';
/// ...
/// }
/// }
/// ```
///
/// dispose 时自动 cancel Timer,子类 `super.dispose()` 即可。
mixin SmsCountdownMixin<T extends StatefulWidget> on State<T> {
int _countdown = 0;
Timer? _timer;
/// 当前剩余秒数(0 = 未运行)
int get smsCountdown => _countdown;
/// 是否正在倒计时
bool get isSmsCountingDown => _countdown > 0;
/// 启动倒计时(重复调用会取消上一个 Timer 重新开始)。
void startSmsCountdown({int seconds = 60}) {
_timer?.cancel();
setState(() => _countdown = seconds);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) {
t.cancel();
return;
}
setState(() {
_countdown -= 1;
if (_countdown <= 0) t.cancel();
});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
+21
View File
@@ -0,0 +1,21 @@
// 对应 iOS BasicModule/Util/Validator.swift
// 所有校验统一在此,禁止在各 Page 内重复定义
abstract class Validator {
static final _phoneReg = RegExp(r'^1[3-9]\d{9}$');
static final _idCardReg = RegExp(r'^\d{17}[\dXx]$');
// 密码:≥8位,含大小写字母和数字(iOS 原规则 ≥12 位,按需调整)
static final _passwordReg = RegExp(
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d!@#$%^&*]{8,}$',
);
static bool isValidPhone(String phone) => _phoneReg.hasMatch(phone);
static bool isValidIdCard(String id) => _idCardReg.hasMatch(id);
static bool isValidPassword(String pwd) => _passwordReg.hasMatch(pwd);
static bool isValidEmail(String email) => email.contains('@');
static bool isNotEmpty(String? value) =>
value != null && value.trim().isNotEmpty;
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
/// 带 loading 状态的通用 FilledButton。
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.width = double.infinity,
this.height = 50.0,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: FilledButton(
onPressed: loading ? null : onPressed,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(label),
),
);
}
}
/// 带 loading 状态的通用 OutlinedButton。
class AppOutlinedButton extends StatelessWidget {
const AppOutlinedButton({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
this.width = double.infinity,
this.height = 50.0,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
final double width;
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
height: height,
child: OutlinedButton(
onPressed: loading ? null : onPressed,
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
)
: Text(label),
),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// 带标签、错误提示、前/后缀的通用输入框。
class AppTextField extends StatelessWidget {
const AppTextField({
super.key,
this.controller,
this.label,
this.hint,
this.errorText,
this.prefixIcon,
this.suffix,
this.obscureText = false,
this.keyboardType,
this.onChanged,
this.onSubmitted,
this.maxLength,
this.enabled = true,
this.readOnly = false,
this.focusNode,
});
final TextEditingController? controller;
final String? label;
final String? hint;
final String? errorText;
final Widget? prefixIcon;
final Widget? suffix;
final bool obscureText;
final TextInputType? keyboardType;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final int? maxLength;
final bool enabled;
final bool readOnly;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
focusNode: focusNode,
obscureText: obscureText,
keyboardType: keyboardType,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: maxLength,
enabled: enabled,
readOnly: readOnly,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
prefixIcon: prefixIcon,
suffix: suffix,
counterText: '',
),
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
enum ToastType { info, success, error, warning }
abstract final class AppToast {
static OverlayEntry? _current;
static void show(
BuildContext context,
String message, {
ToastType type = ToastType.info,
Duration duration = const Duration(seconds: 4),
}) {
_current?.remove();
_current = null;
final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _ToastBanner(
message: message,
type: type,
duration: duration,
onDismiss: () {
entry.remove();
if (_current == entry) _current = null;
},
),
);
_current = entry;
overlay.insert(entry);
}
}
class _ToastBanner extends StatefulWidget {
const _ToastBanner({
required this.message,
required this.type,
required this.duration,
required this.onDismiss,
});
final String message;
final ToastType type;
final Duration duration;
final VoidCallback onDismiss;
@override
State<_ToastBanner> createState() => _ToastBannerState();
}
class _ToastBannerState extends State<_ToastBanner>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<Offset> _slide;
late final Animation<double> _fade;
Timer? _timer;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
);
_slide = Tween<Offset>(
begin: const Offset(0, -1.2),
end: Offset.zero,
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
_fade = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
unawaited(_ctrl.forward());
_timer = Timer(widget.duration, _dismiss);
}
void _dismiss() {
_timer?.cancel();
unawaited(_ctrl.reverse().then((_) => widget.onDismiss()));
}
@override
void dispose() {
_timer?.cancel();
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final (accentColor, icon) = switch (widget.type) {
ToastType.success =>
(const Color(0xFF34C759), Icons.check_circle_outline_rounded),
ToastType.error =>
(const Color(0xFFFF3B30), Icons.error_outline_rounded),
ToastType.warning =>
(const Color(0xFFFF9F0A), Icons.warning_amber_rounded),
ToastType.info =>
(const Color(0xFF6B63D9), Icons.info_outline_rounded),
};
final topPadding = MediaQuery.of(context).padding.top;
return Positioned(
top: topPadding + 10.h,
left: 16.w,
right: 16.w,
child: SlideTransition(
position: _slide,
child: FadeTransition(
opacity: _fade,
child: Material(
elevation: 12,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(14.r),
color: Colors.white,
child: ClipRRect(
borderRadius: BorderRadius.circular(14.r),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(width: 4.w, color: accentColor),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 13.h,
),
child: Row(
children: [
Icon(icon, color: accentColor, size: 20.sp),
SizedBox(width: 10.w),
Expanded(
child: Text(
widget.message,
style: TextStyle(
color: const Color(0xFF1C1C1E),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
height: 1.4,
),
),
),
SizedBox(width: 8.w),
GestureDetector(
onTap: _dismiss,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.all(4.w),
child: Icon(
Icons.close_rounded,
color: const Color(0xFFAEAEB2),
size: 16.sp,
),
),
),
],
),
),
),
],
),
),
),
),
),
),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
/// 用户头像,支持网络图片 + 首字母 fallback。
class AvatarWidget extends StatelessWidget {
const AvatarWidget({
super.key,
this.url,
this.name,
this.size = 40,
});
final String? url;
final String? name;
final double size;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final radius = size / 2;
if (url != null && url!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: url!,
imageBuilder: (_, img) => CircleAvatar(
radius: radius,
backgroundImage: img,
),
placeholder: (_, __) => _placeholder(context),
errorWidget: (_, __, ___) => _placeholder(context),
);
}
return _placeholder(context);
}
Widget _placeholder(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final initial =
(name?.isNotEmpty == true) ? name![0].toUpperCase() : '?';
return CircleAvatar(
radius: size / 2,
backgroundColor: colorScheme.primaryContainer,
child: Text(
initial,
style: TextStyle(
fontSize: size * 0.4,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
/// 通用确认对话框。
///
/// 用法:
/// ```dart
/// final ok = await ConfirmDialog.show(
/// context,
/// title: '删除确认',
/// message: '确定要删除该记录吗?',
/// confirmLabel: '删除',
/// destructive: true,
/// );
/// if (ok == true) { /* do it */ }
/// ```
class ConfirmDialog extends StatelessWidget {
const ConfirmDialog({
super.key,
required this.title,
required this.message,
this.confirmLabel = '确定',
this.cancelLabel = '取消',
this.destructive = false,
});
final String title;
final String message;
final String confirmLabel;
final String cancelLabel;
final bool destructive;
static Future<bool?> show(
BuildContext context, {
required String title,
required String message,
String confirmLabel = '确定',
String cancelLabel = '取消',
bool destructive = false,
}) {
return showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: title,
message: message,
confirmLabel: confirmLabel,
cancelLabel: cancelLabel,
destructive: destructive,
),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
TextButton(
style: destructive
? TextButton.styleFrom(foregroundColor: colorScheme.error)
: null,
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// 带倒计时的发送按钮(常用于短信验证码)。
///
/// 倒计时结束后自动恢复可点击状态。
class CountDownButton extends StatefulWidget {
const CountDownButton({
super.key,
required this.onSend,
this.label = '发送验证码',
this.countingLabel = '重新发送',
this.seconds = 60,
});
final VoidCallback onSend;
final String label;
final String countingLabel;
final int seconds;
@override
State<CountDownButton> createState() => _CountDownButtonState();
}
class _CountDownButtonState extends State<CountDownButton> {
int _remaining = 0;
Timer? _timer;
bool get _isCounting => _remaining > 0;
void _start() {
widget.onSend();
setState(() => _remaining = widget.seconds);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_remaining <= 1) {
t.cancel();
if (mounted) setState(() => _remaining = 0);
} else {
if (mounted) setState(() => _remaining--);
}
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: _isCounting ? null : _start,
child: Text(
_isCounting ? '${widget.countingLabel}(${_remaining}s)' : widget.label,
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// 空状态占位视图。
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.message = '暂无数据',
this.icon,
this.action,
});
final String message;
final Widget? icon;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
icon ??
Icon(
Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.outlineVariant,
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
textAlign: TextAlign.center,
),
if (action != null) ...[const SizedBox(height: 24), action!],
],
),
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
/// 错误状态视图,提供重试按钮。
class ErrorView extends StatelessWidget {
const ErrorView({
super.key,
this.message = '加载失败,请重试',
this.onRetry,
});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 56,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('重试'),
),
],
],
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
/// 标签 + 值 的水平信息行,常用于详情页和调试面板。
class InfoRow extends StatelessWidget {
const InfoRow({
super.key,
required this.label,
required this.value,
this.valueStyle,
this.crossAxisAlignment = CrossAxisAlignment.center,
});
final String label;
final String value;
final TextStyle? valueStyle;
final CrossAxisAlignment crossAxisAlignment;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: [
SizedBox(
width: 88,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: valueStyle ?? theme.textTheme.bodySmall,
),
),
],
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
/// 全屏/局部 loading 遮罩。
///
/// 用法:
/// ```dart
/// LoadingOverlay(
/// loading: _isLoading,
/// child: MyWidget(),
/// )
/// ```
class LoadingOverlay extends StatelessWidget {
const LoadingOverlay({
super.key,
required this.child,
this.loading = false,
this.label,
});
final Widget child;
final bool loading;
final String? label;
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (loading)
Positioned.fill(
child: ColoredBox(
color: Colors.black26,
child: Center(
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (label != null) ...[
const SizedBox(height: 12),
Text(label!,
style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
),
),
),
),
],
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// 带圆角边框的内容分组卡片。
class SectionCard extends StatelessWidget {
const SectionCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(padding: padding, child: child),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// 带左侧装饰条的区块标题。
class SectionTitle extends StatelessWidget {
const SectionTitle(this.title, {super.key, this.trailing});
final String title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Container(
width: 3,
height: 16,
decoration: BoxDecoration(
color: primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
),
if (trailing != null) trailing!,
],
),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 底部导航 Shell — 2 TabHome / Mine)。
/// Tab 标签文字在 Task 5 替换为 i18n 字符串。
class ShellScaffold extends StatelessWidget {
const ShellScaffold({super.key, required this.shell});
final StatefulNavigationShell shell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (index) => shell.goBranch(
index,
initialLocation: index == shell.currentIndex,
),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home', // TODO: i18n → t.tab.home
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Mine', // TODO: i18n → t.tab.mine
),
],
),
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// 骨架屏占位矩形。
///
/// 用法(配合 shimmer 包):
/// ```dart
/// Shimmer.fromColors(
/// baseColor: Colors.grey[300]!,
/// highlightColor: Colors.grey[100]!,
/// child: SkeletonBox(width: double.infinity, height: 20),
/// )
/// ```
class SkeletonBox extends StatelessWidget {
const SkeletonBox({
super.key,
this.width,
this.height = 14,
this.borderRadius = 6,
});
final double? width;
final double height;
final double borderRadius;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(borderRadius),
),
);
}
}
/// 骨架屏圆形(头像占位)。
class SkeletonCircle extends StatelessWidget {
const SkeletonCircle({super.key, this.size = 40});
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// 彩色标签 Chip,常用于状态、分类标注。
class TagChip extends StatelessWidget {
const TagChip({
super.key,
required this.label,
this.color,
this.textColor,
this.fontSize = 12,
});
final String label;
final Color? color;
final Color? textColor;
final double fontSize;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
final bg = color ?? primary.withValues(alpha: 0.12);
final fg = textColor ?? primary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: TextStyle(
color: fg,
fontSize: fontSize,
fontWeight: FontWeight.w500,
),
),
);
}
}