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';