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