Template
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:
@@ -0,0 +1,43 @@
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
|
||||
/// API 网络配置 — **baseUrl 决策的单一权威**
|
||||
///
|
||||
/// **对应 iOS BasicModule/Configuration/NetworkConfig.swift(2026-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);
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 内绝对不能抛出异常
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Base64(X.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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/Talker;userMessage 由 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-10:RSA 加密失败统一映射为 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 '服务器开小差了,请稍后再试';
|
||||
// 业务码 / 4xx:message 已经过 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 => '遇到了点问题,已自动上报';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? '-';
|
||||
}
|
||||
@@ -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() ?? '-';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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 注 Token,TokenRefresh 在 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;
|
||||
}
|
||||
@@ -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 注 Token,TokenRefresh 在 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 注 Token,TokenRefresh 在 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 注 Token,TokenRefresh 在 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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-10:extra 字段防御性清理 —
|
||||
// SDK 自动 instrumentation(frame 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)'), '***');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-10:PRAGMA 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
@@ -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));
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,14 @@
|
||||
/// 单条业务记录的同步状态机。Drift 表用 SyncColumns mixin 4 字段约定记录此状态。
|
||||
enum SyncStatus {
|
||||
/// 本地新增/修改,待同步到服务端
|
||||
pending,
|
||||
|
||||
/// 正在同步中(避免并发重复发送)
|
||||
syncing,
|
||||
|
||||
/// 已成功同步到服务端
|
||||
synced,
|
||||
|
||||
/// 服务端拒绝/冲突,需要人工或策略解决
|
||||
conflict,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 当作 seed,nav/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 SC(GB2312 子集,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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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-3:0=不合格 / 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('重试'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 底部导航 Shell — 2 Tab(Home / 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
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user