mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-06-25 11:50:16 +08:00
438 lines
12 KiB
Dart
438 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:PiliPlus/common/constants.dart';
|
|
import 'package:PiliPlus/common/widgets/button/icon_button.dart';
|
|
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
|
|
import 'package:PiliPlus/services/logger.dart';
|
|
import 'package:PiliPlus/utils/date_utils.dart';
|
|
import 'package:PiliPlus/utils/page_utils.dart';
|
|
import 'package:PiliPlus/utils/storage.dart';
|
|
import 'package:PiliPlus/utils/storage_key.dart';
|
|
import 'package:PiliPlus/utils/storage_pref.dart';
|
|
import 'package:PiliPlus/utils/utils.dart';
|
|
import 'package:catcher_2/catcher_2.dart';
|
|
import 'package:catcher_2/utils/log_printer.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
|
|
|
const _snackBarDisplayDuration = Duration(seconds: 1);
|
|
|
|
class LogsPage extends StatefulWidget {
|
|
const LogsPage({super.key});
|
|
|
|
@override
|
|
State<LogsPage> createState() => _LogsPageState();
|
|
}
|
|
|
|
class _LogsPageState extends State<LogsPage> {
|
|
List<_ExpandedItem<Report>> logsContent = [];
|
|
_ExpandedItem<_DeviceInfo>? _deviceInfo;
|
|
late bool enableLog = Pref.enableLog;
|
|
|
|
@override
|
|
void initState() {
|
|
_initDeviceInfo();
|
|
getLog();
|
|
super.initState();
|
|
}
|
|
|
|
void _initDeviceInfo() {
|
|
if (Catcher2.instance case final c?) {
|
|
_deviceInfo = _ExpandedItem((
|
|
c.deviceParameters,
|
|
c.applicationParameters,
|
|
c.customParameters,
|
|
));
|
|
}
|
|
}
|
|
|
|
Future<void> getLog() async {
|
|
final logsPath = await LoggerUtils.getLogsPath();
|
|
logsContent = (await logsPath.readAsLines()).reversed.map((i) {
|
|
try {
|
|
final log = Report.fromJson(jsonDecode(i));
|
|
return _ExpandedItem(log);
|
|
} catch (e, s) {
|
|
return _ExpandedItem(
|
|
Report(
|
|
'Parse log failed: $e\n\n\n$i',
|
|
s,
|
|
DateTime.now(),
|
|
const {},
|
|
const {},
|
|
const {},
|
|
null,
|
|
),
|
|
);
|
|
}
|
|
}).toList();
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
void copyLogs() {
|
|
Utils.copyText(
|
|
'```\n${logsContent.join('\n\n')}```',
|
|
needToast: false,
|
|
);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('复制成功'),
|
|
duration: _snackBarDisplayDuration,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> clearLogs() async {
|
|
if (await LoggerUtils.clearLogs()) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('已清空'),
|
|
duration: _snackBarDisplayDuration,
|
|
),
|
|
);
|
|
logsContent.clear();
|
|
setState(() {});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final padding = MediaQuery.viewPaddingOf(context);
|
|
return Scaffold(
|
|
resizeToAvoidBottomInset: false,
|
|
appBar: AppBar(
|
|
title: const Text('日志'),
|
|
actions: [
|
|
PopupMenuButton(
|
|
itemBuilder: (_) => [
|
|
if (kDebugMode)
|
|
PopupMenuItem(
|
|
onTap: () => Timer.periodic(
|
|
const Duration(milliseconds: 3500),
|
|
(timer) {
|
|
Utils.reportError('Manual', StackTrace.current);
|
|
if (timer.tick > 3) {
|
|
timer.cancel();
|
|
if (mounted) getLog();
|
|
}
|
|
},
|
|
),
|
|
child: const Text('引发错误'),
|
|
),
|
|
PopupMenuItem(
|
|
onTap: () {
|
|
enableLog = !enableLog;
|
|
GStorage.setting.put(SettingBoxKey.enableLog, enableLog);
|
|
SmartDialog.showToast('已${enableLog ? '开启' : '关闭'},重启生效');
|
|
},
|
|
child: Text('${enableLog ? '关闭' : '开启'}日志'),
|
|
),
|
|
PopupMenuItem(
|
|
onTap: copyLogs,
|
|
child: const Text('复制日志'),
|
|
),
|
|
PopupMenuItem(
|
|
onTap: () =>
|
|
PageUtils.launchURL('${Constants.sourceCodeUrl}/issues'),
|
|
child: const Text('错误反馈'),
|
|
),
|
|
PopupMenuItem(
|
|
onTap: clearLogs,
|
|
child: const Text('清空日志'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 6),
|
|
],
|
|
),
|
|
body: logsContent.isNotEmpty || _deviceInfo != null
|
|
? Padding(
|
|
padding: EdgeInsets.only(
|
|
left: padding.left + 12,
|
|
right: padding.right + 12,
|
|
),
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
if (_deviceInfo != null)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const .only(bottom: 12),
|
|
child: _InfoCard(info: _deviceInfo!),
|
|
),
|
|
),
|
|
SliverPadding(
|
|
padding: EdgeInsets.only(bottom: padding.bottom + 100),
|
|
sliver: SliverList.separated(
|
|
itemCount: logsContent.length,
|
|
itemBuilder: (context, index) =>
|
|
_ReportCard(report: logsContent[index]),
|
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: scrollableError,
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef _DeviceInfo = (
|
|
Map<String, dynamic>,
|
|
Map<String, dynamic>,
|
|
Map<String, dynamic>,
|
|
);
|
|
|
|
class _InfoCard extends StatelessWidget {
|
|
final _ExpandedItem<_DeviceInfo> info;
|
|
|
|
const _InfoCard({required this.info});
|
|
|
|
Widget _buildMapSection(
|
|
Color color,
|
|
String title,
|
|
Map<String, dynamic> map,
|
|
) {
|
|
if (map.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Column(
|
|
spacing: 4,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
title,
|
|
style: TextStyle(fontWeight: .bold, color: color, fontSize: 15),
|
|
),
|
|
...map.entries.map(
|
|
(entry) => Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: '• ${entry.key}: ',
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
TextSpan(
|
|
text: entry.value.toString(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = ColorScheme.of(context);
|
|
return _card([
|
|
Row(
|
|
spacing: 8,
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 22,
|
|
color: colorScheme.primary,
|
|
),
|
|
const Expanded(
|
|
child: Text(
|
|
'相关信息',
|
|
style: TextStyle(fontWeight: .bold, fontSize: 15),
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
),
|
|
),
|
|
iconButton(
|
|
size: 34,
|
|
iconSize: 22,
|
|
tooltip: info.isExpanded ? '收起' : '展开',
|
|
icon: Icon(
|
|
info.isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
),
|
|
onPressed: () {
|
|
info.isExpanded = !info.isExpanded;
|
|
(context as Element).markNeedsBuild();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
if (info.isExpanded) ...[
|
|
_buildMapSection(colorScheme.primary, '设备信息', info.item.$1),
|
|
_buildMapSection(colorScheme.primary, '应用信息', info.item.$2),
|
|
_buildMapSection(colorScheme.primary, '编译信息', info.item.$3),
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
|
|
class _ReportCard extends StatelessWidget {
|
|
final _ExpandedItem<Report> report;
|
|
|
|
const _ReportCard({required this.report});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = ColorScheme.of(context);
|
|
late final stackTrace = PrettyLogPrinter.formatStackString(
|
|
report.item.stackTrace?.toString(),
|
|
-1,
|
|
);
|
|
final dateTime = DateFormatUtils.longFormatDs.format(report.item.dateTime);
|
|
return _card([
|
|
Row(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
spacing: 6,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
report.item.error.toString(),
|
|
style: const TextStyle(fontWeight: .bold, fontSize: 15),
|
|
maxLines: 2,
|
|
overflow: .ellipsis,
|
|
),
|
|
Text(
|
|
dateTime,
|
|
style: TextStyle(
|
|
height: 1.2,
|
|
color: colorScheme.outline,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
iconButton(
|
|
size: 34,
|
|
iconSize: 22,
|
|
tooltip: '复制',
|
|
onPressed: () {
|
|
Utils.copyText('```\n$report```', needToast: false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('已将 $dateTime 复制至剪贴板'),
|
|
duration: _snackBarDisplayDuration,
|
|
),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.copy_outlined, size: 16),
|
|
),
|
|
iconButton(
|
|
size: 34,
|
|
iconSize: 22,
|
|
tooltip: report.isExpanded ? '收起' : '展开',
|
|
icon: Icon(
|
|
report.isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
),
|
|
onPressed: () {
|
|
report.isExpanded = !report.isExpanded;
|
|
(context as Element).markNeedsBuild();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
if (report.isExpanded) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'错误详情',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.error,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const .all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
borderRadius: const .all(.circular(8)),
|
|
border: .all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
|
),
|
|
child: SelectableText(
|
|
report.item.error.toString(),
|
|
style: TextStyle(
|
|
fontFamily: 'Monospace',
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
// stackTrace may be null or String("null") or blank
|
|
if (stackTrace != null && stackTrace.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'堆栈跟踪',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.error,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const .all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
borderRadius: const .all(.circular(8)),
|
|
border: .all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
|
),
|
|
child: SelectableText.rich(
|
|
TextSpan(
|
|
children: stackTrace
|
|
.map(
|
|
(i) => TextSpan(
|
|
text: '$i\n',
|
|
style: i.contains('(package:${Constants.appName}')
|
|
? TextStyle(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: .w600,
|
|
)
|
|
: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
style: const TextStyle(fontFamily: 'Monospace', fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
|
|
Widget _card(List<Widget> contents) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const .all(12),
|
|
child: Column(
|
|
crossAxisAlignment: .stretch,
|
|
children: contents,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ExpandedItem<T> {
|
|
bool isExpanded = false;
|
|
final T item;
|
|
|
|
_ExpandedItem(this.item);
|
|
|
|
@override
|
|
String toString() => item.toString();
|
|
}
|