import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:share_plus/share_plus.dart'; import 'package:vaani/features/logging/providers/logs_provider.dart'; import 'package:vaani/main.dart'; import 'package:vaani/settings/metadata/metadata_provider.dart'; class LogsPage extends HookConsumerWidget { const LogsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final logs = ref.watch(logsProvider); final theme = Theme.of(context); final scrollController = useScrollController(); return Scaffold( appBar: AppBar( title: const Text('Logs'), actions: [ IconButton( tooltip: 'Clear logs', icon: const Icon(Icons.delete_forever), onPressed: () async { // ask for confirmation final shouldClear = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Clear logs?'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(false); }, child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.of(context).pop(true); }, child: const Text('Clear'), ), ], ); }, ); if (shouldClear == true) { ref.read(logsProvider.notifier).clear(); } }, ), IconButton( tooltip: 'Share logs', icon: const Icon(Icons.share), onPressed: () async { appLogger.info('Preparing logs for sharing'); final zipLogFilePath = await ref.read(logsProvider.notifier).getZipFilePath(); // submit logs final result = await Share.shareXFiles([XFile(zipLogFilePath)]); switch (result.status) { case ShareResultStatus.success: appLogger.info('Share success'); break; case ShareResultStatus.dismissed: appLogger.info('Share dismissed'); break; case ShareResultStatus.unavailable: appLogger.severe('Share unavailable'); break; } }, ), IconButton( tooltip: 'Download logs', icon: const Icon(Icons.download), onPressed: () async { appLogger.info('Preparing logs for download'); if (Platform.isAndroid) { final androidVersion = await ref.watch(deviceSdkVersionProvider.future); if ((int.parse(androidVersion)) > 29) { final status = await Permission.manageExternalStorage.status; if (!status.isGranted) { appLogger .info('Requesting manageExternalStorage permission'); final newStatus = await Permission.manageExternalStorage.request(); if (!newStatus.isGranted) { appLogger .warning('manageExternalStorage permission denied'); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Storage permission denied'), ), ); return; } } } else { final status = await Permission.storage.status; if (!status.isGranted) { appLogger.info('Requesting storage permission'); final newStatus = await Permission.storage.request(); if (!newStatus.isGranted) { appLogger.warning('Storage permission denied'); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Storage permission denied'), ), ); return; } } } } final zipLogFilePath = await ref.read(logsProvider.notifier).getZipFilePath(); // save to folder String? outputFile = await FilePicker.platform.saveFile( dialogTitle: 'Please select an output file:', fileName: zipLogFilePath.split('/').last, bytes: await File(zipLogFilePath).readAsBytes(), ); if (outputFile != null) { try { final file = File(outputFile); final zipFile = File(zipLogFilePath); await zipFile.copy(file.path); appLogger.info('File saved to: $outputFile'); } catch (e) { appLogger.severe('Error saving file: $e'); } } else { appLogger.info('Download cancelled'); } }, ), IconButton( tooltip: 'Refresh logs', icon: const Icon(Icons.refresh), onPressed: () { ref.invalidate(logsProvider); }, ), IconButton( tooltip: 'Scroll to top', icon: const Icon(Icons.arrow_upward), onPressed: () { scrollController.animateTo( 0, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, ); }, ), ], ), // a column with listview.builder and a scrollable list of logs body: Column( children: [ // a filter for log levels, loggers, and search // TODO: implement filters and search Expanded( child: logs.when( data: (logRecords) { return Scrollbar( controller: scrollController, child: ListView.builder( controller: scrollController, itemCount: logRecords.length, itemBuilder: (context, index) { final logRecord = logRecords[index]; return LogRecordTile(logRecord: logRecord, theme: theme); }, ), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stackTrace) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Error loading logs'), ElevatedButton( onPressed: () { ref.invalidate(logsProvider); }, child: const Text('Retry'), ), ], ), ), ), ), ], ), ); } } class LogRecordTile extends StatelessWidget { final LogRecord logRecord; final ThemeData theme; const LogRecordTile({ required this.logRecord, required this.theme, super.key, }); @override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( backgroundColor: getLogLevelColor(logRecord.level, theme), child: Icon( getLogLevelIcon(logRecord.level), color: getLogLevelTextColor(logRecord.level, theme), ), ), title: Text(logRecord.loggerName), subtitle: Text(logRecord.message), onTap: () { showDialog( context: context, builder: (context) { return AlertDialog( icon: Icon(getLogLevelIcon(logRecord.level)), title: Text(logRecord.loggerName), content: Text.rich( TextSpan( children: [ TextSpan( text: logRecord.time.toIso8601String(), style: const TextStyle(fontStyle: FontStyle.italic), ), const TextSpan(text: '\n\n'), TextSpan( text: logRecord.message, ), ], ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Close'), ), ], ); }, ); }, ); } } IconData getLogLevelIcon(Level level) { switch (level) { case Level.INFO: return (Icons.info); case Level.WARNING: return (Icons.warning); case Level.SEVERE: case Level.SHOUT: return (Icons.error); default: return (Icons.bug_report); } } Color? getLogLevelColor(Level level, ThemeData theme) { switch (level) { case Level.INFO: return theme.colorScheme.surfaceContainerLow; case Level.WARNING: return theme.colorScheme.surfaceBright; case Level.SEVERE: case Level.SHOUT: return theme.colorScheme.errorContainer; default: return theme.colorScheme.primaryContainer; } } Color? getLogLevelTextColor(Level level, ThemeData theme) { switch (level) { case Level.INFO: return theme.colorScheme.onSurface; case Level.WARNING: return theme.colorScheme.onSurface; case Level.SEVERE: case Level.SHOUT: return theme.colorScheme.onErrorContainer; default: return theme.colorScheme.onPrimaryContainer; } }