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

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

通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/error/exception_mapper.dart';
import 'package:sunny_mochi/core/widgets/app_toast.dart';
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
extension BuildContextX on BuildContext {
/// 弹一个 Toast 风格的 SnackBar(对应 iOS Mkt.makeToast(...))。
void showToast(
String message, {
ToastType type = ToastType.info,
Duration duration = const Duration(seconds: 4),
}) => AppToast.show(this, message, type: type, duration: duration);
/// 异常 → ExceptionMapper.fromUnknown(e, st) → toast(Failure.message)。
///
/// 兜底替代散落的 `on Object catch (e, st) { mapper.fromUnknown; if(mounted) showToast }`
/// 模板(5+ 处 mine 表单提交流程)。
void showError(WidgetRef ref, Object error, StackTrace stackTrace) {
final failure =
ref.read(exceptionMapperProvider).fromUnknown(error, stackTrace);
// 写入本地错误日志(fire-and-forget,不阻塞 UI
unawaited(ref.read(errorLoggerProvider).log(failure));
if (mounted) showToast(failure.userMessage, type: ToastType.error);
}
/// 当前 Theme 快捷读取。
ThemeData get theme => Theme.of(this);
ColorScheme get colors => theme.colorScheme;
TextTheme get textTheme => theme.textTheme;
/// 屏幕信息快捷读取。
Size get screenSize => MediaQuery.sizeOf(this);
EdgeInsets get screenPadding => MediaQuery.paddingOf(this);
double get screenWidth => screenSize.width;
double get screenHeight => screenSize.height;
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:intl/intl.dart';
extension DateTimeX on DateTime {
/// yyyy-MM-dd
String get fmtDate => DateFormat('yyyy-MM-dd').format(this);
/// yyyy-MM-dd HH:mm:ss
String get fmtDateTime => DateFormat('yyyy-MM-dd HH:mm:ss').format(this);
/// HH:mm
String get fmtHm => DateFormat('HH:mm').format(this);
/// 友好相对时间:"刚刚 / 5 分钟前 / 昨天 18:24 / 03-15 / 2024-03-15"
String get toFriendly {
final now = DateTime.now();
final diff = now.difference(this);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24 && now.day == day) {
return '今天 $fmtHm';
}
if (diff.inDays < 2 && now.day - day == 1) {
return '昨天 $fmtHm';
}
if (year == now.year) {
return DateFormat('MM-dd HH:mm').format(this);
}
return fmtDate;
}
}
extension NullableDateTimeX on DateTime? {
String get orPlaceholder => this?.fmtDateTime ?? '-';
String get orPlaceholderDate => this?.fmtDate ?? '-';
}
+25
View File
@@ -0,0 +1,25 @@
extension NumX on num {
/// 金额格式化:1234.5 → "1,234.50"
String get yuan =>
'¥${toStringAsFixed(2).replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+\.)'),
(m) => '${m[1]},',
)}';
/// 百分比:0.75 → "75%"
String get percent => '${(this * 100).toStringAsFixed(0)}%';
/// 文件大小:1024 → "1 KB"
String get bytesFmt {
if (this < 1024) return '$this B';
if (this < 1024 * 1024) return '${(this / 1024).toStringAsFixed(1)} KB';
if (this < 1024 * 1024 * 1024) {
return '${(this / 1024 / 1024).toStringAsFixed(1)} MB';
}
return '${(this / 1024 / 1024 / 1024).toStringAsFixed(1)} GB';
}
}
extension NullableNumX on num? {
String get orPlaceholder => this?.toString() ?? '-';
}
+59
View File
@@ -0,0 +1,59 @@
/// 对应 iOS BasicModule/Extension/Extension+Optional.swift。
extension NullableStringX on String? {
/// null 或空字符串时返回 "-"(对应 iOS orPlaceholder)。
/// UI 层硬约束:所有 Optional 字符串展示前必须 .orPlaceholder。
String get orPlaceholder {
if (this == null || this!.isEmpty) return '-';
return this!;
}
/// 简洁的非空判断(替代散落的 `s != null && s!.isNotEmpty` 三段式)。
bool get isNotNullOrEmpty => this != null && this!.isNotEmpty;
/// 反向:null 或空。
bool get isNullOrEmpty => this == null || this!.isEmpty;
}
extension NullableIntX on int? {
String get orPlaceholder => this?.toString() ?? '-';
}
extension NullableDoubleX on double? {
String get orPlaceholder => this?.toString() ?? '-';
}
extension StringX on String {
/// 首字母大写。
String get capitalize =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
/// 中国大陆手机号校验(对应 iOS Validator.isValidPhone)。
bool get isMobile => RegExp(r'^1[3-9]\d{9}$').hasMatch(this);
/// 身份证号校验(对应 iOS Validator.isValidIDCard)。
bool get isIdCard => RegExp(r'^\d{17}[\dXx]$').hasMatch(this);
/// Email 简易校验。
bool get isEmail => RegExp(r'^[\w.+-]+@([\w-]+\.)+[\w-]{2,}$').hasMatch(this);
/// 脱敏手机号(保留首 3 + 末 4,中间 4 个 *):13800138000 → 138****8000
String get masked {
if (length < 7) return this;
return '${substring(0, 3)}****${substring(length - 4)}';
}
/// 密码强度(0=无 / 1=弱 / 2=中 / 3=强)。
/// 规则:长度 ≥6 +1;含大小写字母 +1;含数字 +1;含特殊符号 +1(最高 3)。
int get passwordStrength {
if (length < 6) return 0;
var score = 1;
if (RegExp('[A-Z]').hasMatch(this) && RegExp('[a-z]').hasMatch(this)) {
score++;
}
if (RegExp(r'\d').hasMatch(this)) score++;
if (RegExp(r'[!@#$%^&*(),.?":{}|<>_+\-=\[\]/\\;~`]').hasMatch(this)) {
score++;
}
return score.clamp(0, 3);
}
}