chore: Added simple option to view/copy crash-logs locally (#185)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2024-12-20 15:48:05 +01:00 committed by GitHub
parent 0cf1b5aef8
commit 0ae613a666
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 306 additions and 58 deletions

View file

@ -1147,5 +1147,6 @@
"mediaSegmentPreview": "Preview",
"mediaSegmentRecap": "Recap",
"mediaSegmentOutro": "Outro",
"mediaSegmentIntro": "Intro"
"mediaSegmentIntro": "Intro",
"errorLogs": "Error logs"
}

View file

@ -11,7 +11,6 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
@ -21,6 +20,7 @@ import 'package:window_manager/window_manager.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/crash_log_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/sync_provider.dart';
@ -50,7 +50,7 @@ Future<Map<String, dynamic>> loadConfig() async {
}
void main() async {
_setupLogging();
final crashProvider = CrashLogNotifier();
WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
@ -88,6 +88,7 @@ void main() async {
overrides: [
sharedPreferencesProvider.overrideWith((ref) => sharedPreferences),
applicationInfoProvider.overrideWith((ref) => applicationInfo),
crashLogProvider.overrideWith((ref) => crashProvider),
syncProvider.overrideWith((ref) => SyncNotifier(
ref,
!kIsWeb
@ -112,15 +113,6 @@ void main() async {
);
}
void _setupLogging() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((rec) {
if (kDebugMode) {
print('${rec.level.name}: ${rec.time}: ${rec.message}');
}
});
}
class Main extends ConsumerStatefulWidget with WindowListener {
const Main({super.key});

View file

@ -0,0 +1,106 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
enum ErrorType {
severe,
warning,
shout,
}
class ErrorViewModel {
final LogRecord rec;
const ErrorViewModel({required this.rec});
ErrorType get type {
if (rec.level == Level.WARNING) {
return ErrorType.warning;
}
if (rec.level == Level.SHOUT) {
return ErrorType.shout;
}
return ErrorType.severe;
}
String get label {
var join = [
type.name.toUpperCase(),
" | ",
rec.message,
].join();
if (join.length > 250) {
return "${join.substring(0, 80)}... \n \nTruncated copy log to see more";
} else {
return join;
}
}
String get content => [
rec.time.toIso8601String(),
"\n",
"\n",
rec.stackTrace,
].whereNotNull().join();
String get clipBoard => [label, content].toString();
Color get color => switch (type) {
ErrorType.severe => Colors.redAccent,
ErrorType.warning => Colors.orange,
ErrorType.shout => Colors.yellowAccent,
};
}
final crashLogProvider = StateNotifierProvider<CrashLogNotifier, List<ErrorViewModel>>((ref) => CrashLogNotifier());
class CrashLogNotifier extends StateNotifier<List<ErrorViewModel>> {
CrashLogNotifier() : super([]) {
init();
}
late final Logger logger;
final maxLength = 100;
void init() {
logger = Logger.root;
logger.level = Level.ALL;
logger.onRecord.listen(logPrint);
FlutterError.onError = (FlutterErrorDetails details) => logFile(details);
PlatformDispatcher.instance.onError = (error, stack) {
logFile(FlutterErrorDetails(
exception: error,
stack: stack,
library: 'Unhandled',
));
return false;
};
}
void clearLogs() {
state = [];
}
void logPrint(LogRecord rec) {
if (kDebugMode) {
print('${rec.level.name}: ${rec.time}: ${rec.message}');
}
if (rec.level != Level.INFO) {
state = [ErrorViewModel(rec: rec), ...state];
if (state.length >= maxLength) {
state = state.sublist(0, maxLength);
}
}
}
void logFile(FlutterErrorDetails details) {
logger.severe('Flutter error: ${details.exception}', details.exception, details.stack!);
if (details.stack != null && kDebugMode) {
print('${details.stack}');
}
}
}

View file

@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/providers/crash_log_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/enum_selection.dart';
final _selectedWarningProvider = StateProvider<ErrorType?>((ref) => null);
class CrashScreen extends ConsumerWidget {
const CrashScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = ref.read(crashLogProvider.notifier);
final selectedType = ref.watch(_selectedWarningProvider);
final crashLogs =
ref.watch(crashLogProvider).where((value) => selectedType == null ? true : value.type == selectedType);
return Dialog.fullscreen(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
const CloseButton(),
Text(
"Error logs",
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
EnumBox(
current: selectedType == null ? context.localized.all : selectedType.name.capitalize(),
itemBuilder: (context) => [
PopupMenuItem(
value: null,
child: Text(context.localized.all),
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => null),
),
...ErrorType.values.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.name.capitalize()),
onTap: () => ref.read(_selectedWarningProvider.notifier).update((state) => entry),
),
)
],
)
],
),
const Divider(),
if (kDebugMode)
Row(
children: [
ElevatedButton(
onPressed: provider.clearLogs,
child: const Text("Clear"),
),
],
),
if (crashLogs.isNotEmpty)
Flexible(
child: Card(
child: ListView(
padding: const EdgeInsets.all(16),
shrinkWrap: true,
children: crashLogs
.map(
(e) => Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
color: e.color.withOpacity(0.1),
margin: const EdgeInsets.symmetric(vertical: 12),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Card(
color: e.color.withOpacity(0.2),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
e.label,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(color: e.color),
),
),
),
),
IconButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: e.clipBoard));
if (context.mounted) {
fladderSnackbar(context, title: "Copied to clipboard");
}
},
icon: const Icon(Icons.copy_all_rounded),
),
],
),
Text(e.content),
].addInBetween(const SizedBox(height: 16)),
),
),
),
const Divider(),
],
),
)
.toList(),
),
),
)
else
const Text("No crash-logs")
].addInBetween(const SizedBox(height: 12)),
),
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:fladder/screens/crash_screen/crash_screen.dart';
import 'package:fladder/screens/settings/settings_scaffold.dart';
import 'package:fladder/screens/shared/fladder_icon.dart';
import 'package:fladder/screens/shared/fladder_logo.dart';
@ -96,6 +97,18 @@ class AboutSettingsPage extends ConsumerWidget {
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.tonal(
onPressed: () => showDialog(
context: context,
builder: (context) => const CrashScreen(),
),
child: Text(context.localized.errorLogs),
)
],
),
].addInBetween(const SizedBox(height: 16)),
),
);

View file

@ -502,54 +502,56 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
),
),
],
const SizedBox(height: 64),
SettingsListTile(
label: Text(
context.localized.clearAllSettings,
),
contentColor: Theme.of(context).colorScheme.error,
onTap: () {
showDialog(
context: context,
builder: (context) => Dialog(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.localized.clearAllSettingsQuestion,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Text(
context.localized.unableToReverseAction,
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.localized.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
await ref.read(sharedPreferencesProvider).clear();
context.router.push(const LoginRoute());
},
child: Text(context.localized.clear),
)
],
),
],
if (kDebugMode) ...[
const SizedBox(height: 64),
SettingsListTile(
label: Text(
context.localized.clearAllSettings,
),
contentColor: Theme.of(context).colorScheme.error,
onTap: () {
showDialog(
context: context,
builder: (context) => Dialog(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.localized.clearAllSettingsQuestion,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Text(
context.localized.unableToReverseAction,
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.localized.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
await ref.read(sharedPreferencesProvider).clear();
context.router.push(const LoginRoute());
},
child: Text(context.localized.clear),
)
],
),
],
),
),
),
),
);
},
),
);
},
),
],
const SizedBox(height: 16),
],
),