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,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