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,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';
|
||||
Reference in New Issue
Block a user