diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 092afe5..3238b0c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1147,5 +1147,6 @@ "mediaSegmentPreview": "Preview", "mediaSegmentRecap": "Recap", "mediaSegmentOutro": "Outro", - "mediaSegmentIntro": "Intro" + "mediaSegmentIntro": "Intro", + "errorLogs": "Error logs" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 9e3fb7f..c0daebb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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> 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}); diff --git a/lib/providers/crash_log_provider.dart b/lib/providers/crash_log_provider.dart new file mode 100644 index 0000000..54fa4d6 --- /dev/null +++ b/lib/providers/crash_log_provider.dart @@ -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>((ref) => CrashLogNotifier()); + +class CrashLogNotifier extends StateNotifier> { + 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}'); + } + } +} diff --git a/lib/screens/crash_screen/crash_screen.dart b/lib/screens/crash_screen/crash_screen.dart new file mode 100644 index 0000000..a6749b5 --- /dev/null +++ b/lib/screens/crash_screen/crash_screen.dart @@ -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((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)), + ), + ), + ); + } +} diff --git a/lib/screens/settings/about_settings_page.dart b/lib/screens/settings/about_settings_page.dart index 2c35a57..2e0f87c 100644 --- a/lib/screens/settings/about_settings_page.dart +++ b/lib/screens/settings/about_settings_page.dart @@ -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)), ), ); diff --git a/lib/screens/settings/client_settings_page.dart b/lib/screens/settings/client_settings_page.dart index 6a491d1..d77c0e3 100644 --- a/lib/screens/settings/client_settings_page.dart +++ b/lib/screens/settings/client_settings_page.dart @@ -502,54 +502,56 @@ class _ClientSettingsPageState extends ConsumerState { ), ), ], - 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), ], ),