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
@@ -0,0 +1,78 @@
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:riverpod_annotation/riverpod_annotation.dart';
part 'device_info_service.g.dart';
class DeviceSnapshot {
const DeviceSnapshot({
required this.deviceModel,
required this.osVersion,
required this.appVersion,
required this.appBuild,
});
final String deviceModel;
final String osVersion;
final String appVersion;
final String appBuild;
Map<String, String> toJson() => {
'deviceModel': deviceModel,
'osVersion': osVersion,
'appVersion': appVersion,
'appBuild': appBuild,
};
}
@riverpod
DeviceInfoService deviceInfoService(Ref ref) => const DeviceInfoService();
class DeviceInfoService {
const DeviceInfoService();
DeviceInfoPlugin get _plugin => DeviceInfoPlugin();
Future<DeviceSnapshot> snapshot() async {
final pkg = await PackageInfo.fromPlatform();
if (kIsWeb) {
return DeviceSnapshot(
deviceModel: 'Web',
osVersion: 'Web',
appVersion: pkg.version,
appBuild: pkg.buildNumber,
);
}
if (Platform.isAndroid) {
final info = await _plugin.androidInfo;
return DeviceSnapshot(
deviceModel: '${info.manufacturer} ${info.model}',
osVersion: 'Android ${info.version.release}',
appVersion: pkg.version,
appBuild: pkg.buildNumber,
);
}
if (Platform.isIOS) {
final info = await _plugin.iosInfo;
return DeviceSnapshot(
deviceModel: info.model,
osVersion: '${info.systemName} ${info.systemVersion}',
appVersion: pkg.version,
appBuild: pkg.buildNumber,
);
}
return DeviceSnapshot(
deviceModel: Platform.operatingSystem,
osVersion: Platform.operatingSystemVersion,
appVersion: pkg.version,
appBuild: pkg.buildNumber,
);
}
}
@@ -0,0 +1,57 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_info_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(deviceInfoService)
final deviceInfoServiceProvider = DeviceInfoServiceProvider._();
final class DeviceInfoServiceProvider
extends
$FunctionalProvider<
DeviceInfoService,
DeviceInfoService,
DeviceInfoService
>
with $Provider<DeviceInfoService> {
DeviceInfoServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'deviceInfoServiceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$deviceInfoServiceHash();
@$internal
@override
$ProviderElement<DeviceInfoService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
DeviceInfoService create(Ref ref) {
return deviceInfoService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(DeviceInfoService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<DeviceInfoService>(value),
);
}
}
String _$deviceInfoServiceHash() => r'a529d8f8673a6207e1c73bc69db4555857056c57';
@@ -0,0 +1,157 @@
import 'package:drift/drift.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/storage/app_database.dart';
import 'package:sunny_mochi/features/error_report/data/device_info_service.dart';
part 'error_logger.g.dart';
enum ErrorKind {
network,
server,
auth,
cache,
unknown;
String get displayLabel => switch (this) {
ErrorKind.network => '网络错误',
ErrorKind.server => '接口错误',
ErrorKind.auth => '认证错误',
ErrorKind.cache => '本地错误',
ErrorKind.unknown => '未知错误',
};
static ErrorKind from(String name) =>
ErrorKind.values.firstWhere((k) => k.name == name,
orElse: () => ErrorKind.unknown);
}
class ErrorEntry {
const ErrorEntry({
required this.id,
required this.kind,
required this.message,
required this.displayMessage,
required this.occurredAt,
required this.reported,
this.code,
this.statusCode,
});
factory ErrorEntry.fromRow(ErrorLogRow row) => ErrorEntry(
id: row.id,
kind: ErrorKind.from(row.kind),
message: row.message,
displayMessage: row.displayMessage,
code: row.code,
statusCode: row.statusCode,
occurredAt: row.occurredAt,
reported: row.reported,
);
final int id;
final ErrorKind kind;
final String message;
final String displayMessage;
final String? code;
final int? statusCode;
final DateTime occurredAt;
final bool reported;
}
extension _FailureKind on Failure {
ErrorKind get kind => switch (this) {
NetworkFailure() => ErrorKind.network,
ServerFailure() => ErrorKind.server,
AuthFailure() => ErrorKind.auth,
CacheFailure() => ErrorKind.cache,
UnknownFailure() => ErrorKind.unknown,
};
}
@Riverpod(keepAlive: true)
ErrorLogger errorLogger(Ref ref) => ErrorLogger(
ref.watch(appDatabaseProvider),
ref.watch(deviceInfoServiceProvider),
);
class ErrorLogger {
ErrorLogger(this._db, this._deviceService);
final AppDatabase _db;
final DeviceInfoService _deviceService;
DeviceSnapshot? _cachedDevice;
static const maxEntries = 200;
static const maxRetentionDays = 30;
static const maxBatchUpload = 50;
Future<DeviceSnapshot> _device() async =>
_cachedDevice ??= await _deviceService.snapshot();
Future<void> log(Failure failure) async {
final device = await _device();
await _db.into(_db.errorLogs).insert(
ErrorLogsCompanion.insert(
kind: failure.kind.name,
message: failure.message,
displayMessage: failure.userMessage,
code: Value(failure is ServerFailure ? failure.code : null),
statusCode: Value(
failure is ServerFailure ? failure.statusCode : null,
),
occurredAt: DateTime.now(),
deviceModel: Value(device.deviceModel),
osVersion: Value(device.osVersion),
appVersion: Value(device.appVersion),
appBuild: Value(device.appBuild),
),
);
await _prune();
}
Future<void> _prune() async {
final ids = await (_db.selectOnly(_db.errorLogs)
..addColumns([_db.errorLogs.id])
..orderBy([OrderingTerm.desc(_db.errorLogs.occurredAt)]))
.map((r) => r.read(_db.errorLogs.id)!)
.get();
if (ids.length > maxEntries) {
final toDelete = ids.skip(maxEntries).toList();
await (_db.delete(_db.errorLogs)
..where((t) => t.id.isIn(toDelete)))
.go();
}
}
Stream<List<ErrorEntry>> watchAll() =>
(_db.select(_db.errorLogs)
..orderBy([(t) => OrderingTerm.desc(t.occurredAt)])
..limit(100))
.watch()
.map((rows) => rows.map(ErrorEntry.fromRow).toList());
Future<void> pruneOld() async {
final cutoff = DateTime.now().subtract(
const Duration(days: maxRetentionDays),
);
await (_db.delete(_db.errorLogs)
..where((t) => t.occurredAt.isSmallerOrEqualValue(cutoff)))
.go();
}
Future<List<ErrorLogRow>> getUnreported() =>
(_db.select(_db.errorLogs)
..where((t) => t.reported.equals(false))
..orderBy([(t) => OrderingTerm.desc(t.occurredAt)])
..limit(maxBatchUpload))
.get();
Future<void> markReported(List<int> ids) async {
if (ids.isEmpty) return;
await (_db.update(_db.errorLogs)..where((t) => t.id.isIn(ids)))
.write(const ErrorLogsCompanion(reported: Value(true)));
}
Future<void> clearAll() => _db.delete(_db.errorLogs).go();
}
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'error_logger.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(errorLogger)
final errorLoggerProvider = ErrorLoggerProvider._();
final class ErrorLoggerProvider
extends $FunctionalProvider<ErrorLogger, ErrorLogger, ErrorLogger>
with $Provider<ErrorLogger> {
ErrorLoggerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'errorLoggerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$errorLoggerHash();
@$internal
@override
$ProviderElement<ErrorLogger> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ErrorLogger create(Ref ref) {
return errorLogger(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ErrorLogger value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ErrorLogger>(value),
);
}
}
String _$errorLoggerHash() => r'0486bd08edcaa19da8c2242f759ab8434dfad93e';
@@ -0,0 +1,42 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/network/api_response.dart';
import 'package:sunny_mochi/core/network/dio_client.dart';
import 'package:sunny_mochi/core/storage/app_database.dart';
part 'error_report_datasource.g.dart';
@riverpod
ErrorReportDatasource errorReportDatasource(Ref ref) =>
ErrorReportDatasource(ref.watch(dioClientProvider));
class ErrorReportDatasource {
const ErrorReportDatasource(this._dio);
final Dio _dio;
Future<void> sendReport(List<ErrorLogRow> logs) async {
final resp = await _dio.post<Map<String, dynamic>>(
ApiPaths.errorReport,
data: {
'entries': logs
.map(
(l) => {
'kind': l.kind,
'message': l.message,
'displayMessage': l.displayMessage,
if (l.code != null) 'code': l.code,
if (l.statusCode != null) 'statusCode': l.statusCode,
'occurredAt': l.occurredAt.toUtc().toIso8601String(),
if (l.deviceModel != null) 'deviceModel': l.deviceModel,
if (l.osVersion != null) 'osVersion': l.osVersion,
if (l.appVersion != null) 'appVersion': l.appVersion,
if (l.appBuild != null) 'appBuild': l.appBuild,
},
)
.toList(),
},
);
parseEnvelope(resp.data, (j) => j).unwrapVoid();
}
}
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'error_report_datasource.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(errorReportDatasource)
final errorReportDatasourceProvider = ErrorReportDatasourceProvider._();
final class ErrorReportDatasourceProvider
extends
$FunctionalProvider<
ErrorReportDatasource,
ErrorReportDatasource,
ErrorReportDatasource
>
with $Provider<ErrorReportDatasource> {
ErrorReportDatasourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'errorReportDatasourceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$errorReportDatasourceHash();
@$internal
@override
$ProviderElement<ErrorReportDatasource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ErrorReportDatasource create(Ref ref) {
return errorReportDatasource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ErrorReportDatasource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ErrorReportDatasource>(value),
);
}
}
String _$errorReportDatasourceHash() =>
r'2a3077bdcb8d59bad619607dcea7dea278b382a0';
@@ -0,0 +1,32 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
import 'package:sunny_mochi/features/error_report/data/error_report_datasource.dart';
part 'error_report_notifier.g.dart';
@riverpod
Stream<List<ErrorEntry>> errorLogs(Ref ref) =>
ref.watch(errorLoggerProvider).watchAll();
@riverpod
class ErrorReportSender extends _$ErrorReportSender {
@override
AsyncValue<bool> build() => const AsyncData(true);
Future<void> send() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final logger = ref.read(errorLoggerProvider);
final logs = await logger.getUnreported();
if (logs.isEmpty) return true;
await ref.read(errorReportDatasourceProvider).sendReport(logs);
await logger.markReported(logs.map((l) => l.id).toList());
return true;
});
}
Future<void> clearAll() async {
await ref.read(errorLoggerProvider).clearAll();
state = const AsyncData(true);
}
}
@@ -0,0 +1,101 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'error_report_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(errorLogs)
final errorLogsProvider = ErrorLogsProvider._();
final class ErrorLogsProvider
extends
$FunctionalProvider<
AsyncValue<List<ErrorEntry>>,
List<ErrorEntry>,
Stream<List<ErrorEntry>>
>
with $FutureModifier<List<ErrorEntry>>, $StreamProvider<List<ErrorEntry>> {
ErrorLogsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'errorLogsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$errorLogsHash();
@$internal
@override
$StreamProviderElement<List<ErrorEntry>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<ErrorEntry>> create(Ref ref) {
return errorLogs(ref);
}
}
String _$errorLogsHash() => r'16329046d2b056a007f56835c40e43664ba87533';
@ProviderFor(ErrorReportSender)
final errorReportSenderProvider = ErrorReportSenderProvider._();
final class ErrorReportSenderProvider
extends $NotifierProvider<ErrorReportSender, AsyncValue<bool>> {
ErrorReportSenderProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'errorReportSenderProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$errorReportSenderHash();
@$internal
@override
ErrorReportSender create() => ErrorReportSender();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<bool> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<bool>>(value),
);
}
}
String _$errorReportSenderHash() => r'12c474e98289540bc9307911a2e40049ef49b449';
abstract class _$ErrorReportSender extends $Notifier<AsyncValue<bool>> {
AsyncValue<bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, AsyncValue<bool>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, AsyncValue<bool>>,
AsyncValue<bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:sunny_mochi/core/extensions/context_x.dart';
import 'package:sunny_mochi/core/widgets/app_toast.dart';
import 'package:sunny_mochi/features/error_report/data/error_logger.dart';
import 'package:sunny_mochi/features/error_report/presentation/error_report_notifier.dart';
class ErrorReportPage extends ConsumerWidget {
const ErrorReportPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final logsAsync = ref.watch(errorLogsProvider);
final sender = ref.watch(errorReportSenderProvider);
ref.listen(errorReportSenderProvider, (prev, next) {
if (next is AsyncError) {
context.showError(ref, next.error ?? Exception('未知错误'),
next.stackTrace ?? StackTrace.empty);
} else if (next is AsyncData && prev is AsyncLoading) {
context.showToast('上报成功,感谢您的反馈', type: ToastType.success);
}
});
return Scaffold(
appBar: AppBar(
title: const Text('错误报告'),
actions: [
TextButton(
onPressed: sender.isLoading
? null
: () =>
ref.read(errorReportSenderProvider.notifier).clearAll(),
child: Text(
'清除',
style: TextStyle(
color: sender.isLoading
? const Color(0xFFAEAEB2)
: const Color(0xFFFF3B30),
fontSize: 15.sp,
),
),
),
],
),
body: logsAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator.adaptive()),
error: (e, _) => Center(child: Text('$e')),
data: (logs) =>
logs.isEmpty ? const _EmptyState() : _LogList(logs: logs),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 16.h),
child: FilledButton(
onPressed: sender.isLoading
? null
: () => ref.read(errorReportSenderProvider.notifier).send(),
style: FilledButton.styleFrom(minimumSize: Size.fromHeight(50.h)),
child: sender.isLoading
? SizedBox(
width: 20.w,
height: 20.w,
child: const CircularProgressIndicator.adaptive(
strokeWidth: 2),
)
: const Text('一键发送错误报告'),
),
),
),
);
}
}
class _LogList extends StatelessWidget {
const _LogList({required this.logs});
final List<ErrorEntry> logs;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: EdgeInsets.symmetric(vertical: 12.h),
itemCount: logs.length,
separatorBuilder: (_, _) => Divider(
height: 1,
indent: 16.w,
endIndent: 16.w,
color: const Color(0xFFE5E5EA),
),
itemBuilder: (_, i) => _LogTile(log: logs[i]),
);
}
}
class _LogTile extends StatelessWidget {
const _LogTile({required this.log});
final ErrorEntry log;
@override
Widget build(BuildContext context) {
final (color, bg) = switch (log.kind) {
ErrorKind.network =>
(const Color(0xFFFF9F0A), const Color(0xFFFFF3E0)),
ErrorKind.server =>
(const Color(0xFFFF3B30), const Color(0xFFFFEBEB)),
ErrorKind.auth =>
(const Color(0xFF6B63D9), const Color(0xFFF0EFFF)),
ErrorKind.cache =>
(const Color(0xFF34C759), const Color(0xFFE8F8ED)),
ErrorKind.unknown =>
(const Color(0xFF8E8E93), const Color(0xFFF2F2F7)),
};
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
log.kind.displayLabel,
style: TextStyle(
color: color,
fontSize: 12.sp,
fontWeight: FontWeight.w600),
),
),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(log.displayMessage,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF252535),
fontWeight: FontWeight.w500)),
SizedBox(height: 4.h),
Text(log.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp, color: const Color(0xFF8E8E93))),
SizedBox(height: 4.h),
Text(_fmt(log.occurredAt),
style: TextStyle(
fontSize: 12.sp, color: const Color(0xFFAEAEB2))),
],
),
),
if (log.reported)
Padding(
padding: EdgeInsets.only(left: 8.w),
child: Icon(Icons.check_circle_outline_rounded,
size: 16.sp, color: const Color(0xFF34C759)),
),
],
),
);
}
String _fmt(DateTime dt) {
final t = dt.toLocal();
return '${t.year}-${t.month.toString().padLeft(2, '0')}-'
'${t.day.toString().padLeft(2, '0')} '
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline_rounded,
size: 64.sp, color: const Color(0xFF34C759)),
SizedBox(height: 16.h),
Text('暂无错误记录',
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF8E8E93))),
SizedBox(height: 8.h),
Text('应用运行正常',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFFAEAEB2))),
],
),
);
}
}