import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:logger/logger.dart'; import 'package:paperless_mobile/features/logging/cubit/app_logs_cubit.dart'; import 'package:paperless_mobile/features/logging/models/parsed_log_message.dart'; import 'package:paperless_mobile/core/extensions/dart_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AppLogsPage extends StatefulWidget { const AppLogsPage({super.key}); @override State createState() => _AppLogsPageState(); } class _AppLogsPageState extends State { final ScrollController _scrollController = ScrollController(); bool autoScroll = true; @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context).toString(); final theme = Theme.of(context); return BlocBuilder( builder: (context, state) { final formattedDate = DateFormat.yMMMd(locale).format(state.date); return Scaffold( bottomNavigationBar: BottomAppBar( child: Row( mainAxisAlignment: MainAxisAlignment.start, children: switch (state) { AppLogsStateInitial() => [], AppLogsStateLoading() => [], AppLogsStateLoaded() => [ IconButton( tooltip: S.of(context)!.copyToClipboard, onPressed: () { context .read() .copyToClipboard(state.date); }, icon: const Icon(Icons.copy), ).padded(), IconButton( tooltip: S.of(context)!.saveLogsToFile, onPressed: () { context .read() .saveLogs(state.date, locale); }, icon: const Icon(Icons.download), ).padded(), IconButton( tooltip: S.of(context)!.clearLogs(formattedDate), onPressed: () { context.read().clearLogs(state.date); }, icon: Icon( Icons.delete_sweep, color: Theme.of(context).colorScheme.error, ), ).padded(), ], _ => [], }, ), ), appBar: AppBar( title: Text(S.of(context)!.appLogs(formattedDate)), actions: [ if (state is AppLogsStateLoaded) IconButton( tooltip: MaterialLocalizations.of(context).datePickerHelpText, onPressed: () async { final selectedDate = await showDatePicker( context: context, initialDate: state.date, firstDate: state.availableLogs.first, lastDate: state.availableLogs.last, selectableDayPredicate: (day) => state.availableLogs .any((date) => day.isOnSameDayAs(date)), initialEntryMode: DatePickerEntryMode.calendarOnly, ); if (selectedDate != null) { context.read().loadLogs(selectedDate); } }, icon: const Icon(Icons.calendar_today), ).padded(), ], ), body: switch (state) { AppLogsStateLoaded( logs: var logs, ) => Builder( builder: (context) { if (state.logs.isEmpty) { return Center( child: Text(S.of(context)!.noLogsFoundOn(formattedDate)), ); } return ListView.builder( reverse: true, controller: _scrollController, itemBuilder: (context, index) { if (index == 0) { return Center( child: Text(S.of(context)!.logfileBottomReached, style: theme.textTheme.bodySmall?.copyWith( color: theme.disabledColor, )), ).padded(24); } final messages = state.logs; final logMessage = messages[index - 1]; final altColor = CupertinoDynamicColor.withBrightness( color: Colors.grey.shade200, darkColor: Colors.grey.shade800, ).resolveFrom(context); return ParsedLogMessageTile( message: logMessage, backgroundColor: (index % 2 == 0) ? theme.colorScheme.background : altColor, ); }, itemCount: logs.length + 1, ); }, ), AppLogsStateError() => Center( child: Text(S.of(context)!.couldNotLoadLogfileFrom(formattedDate)), ), _ => _buildLoadingLogs(state.date) }, ); }, ); } Widget _buildLoadingLogs(DateTime date) { final formattedDate = DateFormat.yMd(Localizations.localeOf(context).toString()).format(date); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), Text(S.of(context)!.loadingLogsFrom(formattedDate)), ], ), ); } } class ParsedLogMessageTile extends StatelessWidget { final ParsedLogMessage message; final Color backgroundColor; const ParsedLogMessageTile({ super.key, required this.message, required this.backgroundColor, }); @override Widget build(BuildContext context) { return switch (message) { ParsedFormattedLogMessage m => FormattedLogMessageWidget( message: m, backgroundColor: backgroundColor, ), UnformattedLogMessage(message: var m) => Text(m), }; } } class FormattedLogMessageWidget extends StatelessWidget { final ParsedFormattedLogMessage message; final Color backgroundColor; const FormattedLogMessageWidget( {super.key, required this.message, required this.backgroundColor}); static final _timeFormat = DateFormat("HH:mm:ss.SSS"); @override Widget build(BuildContext context) { final c = Theme.of(context).colorScheme; final icon = switch (message.level) { Level.trace => Icons.troubleshoot, Level.debug => Icons.bug_report, Level.info => Icons.info_outline, Level.warning => Icons.warning, Level.error => Icons.error, Level.fatal => Icons.error_outline, _ => null, }; final color = switch (message.level) { Level.trace => c.onBackground.withOpacity(0.75), Level.warning => Colors.yellow.shade600, Level.error => Colors.red, Level.fatal => Colors.red.shade900, Level.info => Colors.blue, _ => c.onBackground, }; final logStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( fontFamily: 'monospace', fontSize: 12, ); final formattedMethodName = message.methodName != null ? '${message.methodName!.trim()}()' : ''; final source = switch (message.className) { '' || null => formattedMethodName, String className => '$className.$formattedMethodName', }; return Material( color: backgroundColor, child: ExpansionTile( leading: Text( _timeFormat.format(message.timestamp), style: logStyle?.copyWith(color: color), ), title: Text( message.message, style: logStyle?.copyWith(color: color), ), trailing: Icon( icon, color: color, ), expandedCrossAxisAlignment: CrossAxisAlignment.start, childrenPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), expandedAlignment: Alignment.topLeft, children: source.isNotEmpty ? [ Row( children: [ const Icon(Icons.arrow_right), Flexible( child: Text( 'In $source', style: logStyle?.copyWith(fontSize: 14), ), ), ], ), ..._buildErrorWidgets(context), ] : _buildErrorWidgets(context), ), ); } List _buildErrorWidgets(BuildContext context) { if (message.error != null) { return [ Divider(), Text( message.error!.error, style: TextStyle(color: Colors.red), ).padded(), if (message.error?.stackTrace != null) ...[ Text( message.error!.stackTrace!, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', fontSize: 10, ), ).paddedOnly(left: 8), ], ]; } else { return []; } } }