diff --git a/docs/LOGGING_IMPLEMENTATION.md b/docs/LOGGING_IMPLEMENTATION.md index e4dbc445..0208bbd6 100644 --- a/docs/LOGGING_IMPLEMENTATION.md +++ b/docs/LOGGING_IMPLEMENTATION.md @@ -34,11 +34,12 @@ Implementation of a comprehensive logging system for MostroP2P mobile app with i - Desktop background service with isolate logging - Isolate log receiver initialized in main.dart -### Phase 5: File Export & Persistence -- Auto-save to storage -- Restore on app restart -- Generate .txt files +### Phase 5: File Export & Persistence (Completed) +- Manual export via hamburger menu +- Save logs to user-selected location using FilePicker +- Share logs via native system share sheet - Folder picker and permissions +- Clear logs with confirmation dialog ### Phase 6: UI Enhancements - Recording indicator widget @@ -206,6 +207,6 @@ void backgroundMain(SendPort sendPort) async { --- -**Version**: 5 -**Status**: Phase 4 - Completed +**Version**: 6 +**Status**: Phase 5 - Completed **Last Updated**: 2026-01-12 diff --git a/lib/features/logs/screens/logs_screen.dart b/lib/features/logs/screens/logs_screen.dart index 220aa081..6c19c0be 100644 --- a/lib/features/logs/screens/logs_screen.dart +++ b/lib/features/logs/screens/logs_screen.dart @@ -4,11 +4,11 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/features/logs/logs_provider.dart'; +import 'package:mostro_mobile/features/logs/widgets/logs_actions_menu.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/shared/utils/datetime_extensions_utils.dart'; -import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; class LogsScreen extends ConsumerStatefulWidget { const LogsScreen({super.key}); @@ -17,7 +17,7 @@ class LogsScreen extends ConsumerStatefulWidget { ConsumerState createState() => _LogsScreenState(); } -class _LogsScreenState extends ConsumerState { +class _LogsScreenState extends ConsumerState with WidgetsBindingObserver { String? _selectedLevel; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); @@ -28,6 +28,7 @@ class _LogsScreenState extends ConsumerState { void initState() { super.initState(); _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addObserver(this); } void _onScroll() { @@ -50,11 +51,15 @@ class _LogsScreenState extends ConsumerState { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _scrollController.dispose(); _searchController.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) {} + Future _toggleLogging(bool value) async { if (value) { await _showPerformanceWarning(); @@ -110,48 +115,6 @@ class _LogsScreenState extends ConsumerState { } } - Future _showClearConfirmation() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.backgroundCard, - title: Text( - S.of(context)!.clearLogs, - style: TextStyle(color: AppTheme.textPrimary), - ), - content: Text( - S.of(context)!.clearLogsConfirmation, - style: TextStyle(color: AppTheme.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(S.of(context)!.cancel), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.statusError, - ), - child: Text(S.of(context)!.clear), - ), - ], - ), - ); - - if (confirmed == true && mounted) { - ref.read(logsProvider.notifier).clearLogs(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.logsCleared, - ); - } - }); - } - } - @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); @@ -180,11 +143,7 @@ class _LogsScreenState extends ConsumerState { ), iconTheme: const IconThemeData(color: AppTheme.textPrimary), actions: [ - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: logs.isEmpty ? null : _showClearConfirmation, - tooltip: S.of(context)!.clearLogs, - ), + LogsActionsMenu(), ], ), body: SafeArea( diff --git a/lib/features/logs/widgets/logs_actions_menu.dart b/lib/features/logs/widgets/logs_actions_menu.dart new file mode 100644 index 00000000..b64c0bf3 --- /dev/null +++ b/lib/features/logs/widgets/logs_actions_menu.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/logs/logs_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/logger_export_service.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; + +class LogsActionsMenu extends ConsumerWidget { + final _logger = Logger(); + + LogsActionsMenu({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final logs = ref.watch(logsProvider); + final hasLogs = logs.isNotEmpty; + + return PopupMenuButton( + icon: const HeroIcon( + HeroIcons.ellipsisVertical, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 24, + ), + color: AppTheme.backgroundDark, + onSelected: (value) => _handleMenuAction(context, ref, value, logs), + itemBuilder: (context) => [ + _buildMenuItem( + 'save', + HeroIcons.arrowDownTray, + S.of(context)!.saveLogs, + hasLogs ? AppTheme.cream1 : AppTheme.textSecondary, + enabled: hasLogs, + ), + _buildMenuItem( + 'share', + HeroIcons.share, + S.of(context)!.shareLogs, + hasLogs ? AppTheme.cream1 : AppTheme.textSecondary, + enabled: hasLogs, + ), + _buildMenuItem( + 'clear', + HeroIcons.trash, + S.of(context)!.clearLogs, + hasLogs ? AppTheme.statusError : AppTheme.textSecondary, + enabled: hasLogs, + ), + ], + ); + } + + PopupMenuItem _buildMenuItem( + String value, + HeroIcons icon, + String label, + Color color, { + bool enabled = true, + }) { + return PopupMenuItem( + value: value, + enabled: enabled, + child: Row( + children: [ + HeroIcon( + icon, + style: HeroIconStyle.outline, + size: 20, + color: color, + ), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + color: enabled ? AppTheme.textPrimary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Future _handleMenuAction( + BuildContext context, + WidgetRef ref, + String action, + List logs, + ) async { + switch (action) { + case 'save': + await _saveLogsToFolder(context, ref, logs); + break; + case 'share': + await _shareLogsFile(context, logs); + break; + case 'clear': + await _showClearConfirmation(context, ref); + break; + } + } + + Future _saveLogsToFolder( + BuildContext context, + WidgetRef ref, + List logs, + ) async { + final localizations = S.of(context)!; + final strings = LogExportStrings( + headerTitle: localizations.logsHeaderTitle, + generatedLabel: localizations.logsGeneratedLabel, + totalLabel: localizations.logsTotalLabel, + emptyMessage: localizations.noLogsAvailable, + ); + + try { + final filePath = await LoggerExportService.exportLogsToFolder(logs, strings); + + if (filePath != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.logsExportSuccess), + backgroundColor: AppTheme.statusSuccess, + ), + ); + } + } catch (e, stackTrace) { + _logger.e('Error exporting logs', error: e, stackTrace: stackTrace); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.logsExportError), + backgroundColor: AppTheme.statusError, + ), + ); + } + } + } + + Future _shareLogsFile(BuildContext context, List logs) async { + final localizations = S.of(context)!; + final strings = LogExportStrings( + headerTitle: localizations.logsHeaderTitle, + generatedLabel: localizations.logsGeneratedLabel, + totalLabel: localizations.logsTotalLabel, + emptyMessage: localizations.noLogsAvailable, + ); + + try { + final file = await LoggerExportService.exportLogsForSharing(logs, strings); + await LoggerExportService.shareLogs( + file, + subject: localizations.logsShareSubject, + text: localizations.logsShareText, + ); + } catch (e, stackTrace) { + _logger.e('Error sharing logs', error: e, stackTrace: stackTrace); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.shareLogsError), + backgroundColor: AppTheme.statusError, + ), + ); + } + } + } + + Future _showClearConfirmation(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.backgroundDark, + title: Text( + S.of(context)!.clearLogs, + style: const TextStyle(color: AppTheme.textPrimary), + ), + content: Text( + S.of(context)!.clearLogsConfirmation, + style: const TextStyle(color: AppTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + S.of(context)!.cancel, + style: const TextStyle(color: AppTheme.textSecondary), + ), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + S.of(context)!.clear, + style: const TextStyle(color: AppTheme.statusError), + ), + ), + ], + ), + ); + + if (confirmed == true) { + ref.read(logsProvider.notifier).clearLogs(); + } + } +} diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index cbc74c51..4ef66949 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -5,8 +5,8 @@ class Settings { final String? defaultFiatCode; final String? selectedLanguage; // null means use system locale final String? defaultLightningAddress; - final List blacklistedRelays; // Relays blocked by user from auto-sync - final List> userRelays; // User-added relays with metadata + final List blacklistedRelays; + final List> userRelays; final bool isLoggingEnabled; // Push notification settings final bool pushNotificationsEnabled; diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 3fb03134..cc856bed 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1300,6 +1300,16 @@ "devTools": "Dev Tools", "devToolsWarning": "For debugging and troubleshooting only", "viewAndExportLogs": "View and export application logs", + "saveLogs": "Save Logs", + "logsExportSuccess": "Logs exported successfully", + "logsExportError": "Failed to export logs", + "shareLogsError": "Failed to share logs", + "exportSettings": "Export Settings", + "logsHeaderTitle": "Mostro P2P Application Logs", + "logsGeneratedLabel": "Generated", + "logsTotalLabel": "Total logs", + "logsShareSubject": "Mostro P2P Logs", + "logsShareText": "Application logs from Mostro P2P", "pushNotifications": "Push Notifications", "pushNotificationsDescription": "Receive notifications when there are updates to your trades, even when the app is closed.", "pushNotificationsNotSupported": "Push notifications are not supported on this platform.", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e025b918..1e0b7976 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1276,6 +1276,16 @@ "devTools": "Herramientas de Desarrollo", "devToolsWarning": "Solo para depuración y solución de problemas", "viewAndExportLogs": "Ver y exportar registros de la aplicación", + "saveLogs": "Guardar Registros", + "logsExportSuccess": "Registros exportados exitosamente", + "logsExportError": "Error al exportar registros", + "shareLogsError": "Error al compartir registros", + "exportSettings": "Configuración de Exportación", + "logsHeaderTitle": "Registros de Aplicación Mostro P2P", + "logsGeneratedLabel": "Generado", + "logsTotalLabel": "Total de registros", + "logsShareSubject": "Registros Mostro P2P", + "logsShareText": "Registros de aplicación de Mostro P2P", "pushNotifications": "Notificaciones Push", "pushNotificationsDescription": "Recibe notificaciones cuando haya actualizaciones en tus operaciones, incluso cuando la app está cerrada.", "pushNotificationsNotSupported": "Las notificaciones push no están soportadas en esta plataforma.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index b8af8a0a..b2f4159a 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1331,6 +1331,16 @@ "devTools": "Strumenti di Sviluppo", "devToolsWarning": "Solo per debug e risoluzione problemi", "viewAndExportLogs": "Visualizza ed esporta i log dell'applicazione", + "saveLogs": "Salva Log", + "logsExportSuccess": "Log esportati con successo", + "logsExportError": "Impossibile esportare i log", + "shareLogsError": "Impossibile condividere i log", + "exportSettings": "Impostazioni Esportazione", + "logsHeaderTitle": "Log Applicazione Mostro P2P", + "logsGeneratedLabel": "Generato", + "logsTotalLabel": "Totale log", + "logsShareSubject": "Log Mostro P2P", + "logsShareText": "Log dell'applicazione Mostro P2P", "pushNotifications": "Notifiche Push", "pushNotificationsDescription": "Ricevi notifiche quando ci sono aggiornamenti sulle tue operazioni, anche quando l'app è chiusa.", "pushNotificationsNotSupported": "Le notifiche push non sono supportate su questa piattaforma.", diff --git a/lib/services/logger_export_service.dart b/lib/services/logger_export_service.dart new file mode 100644 index 00000000..e13e89ab --- /dev/null +++ b/lib/services/logger_export_service.dart @@ -0,0 +1,94 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; + +class LogExportStrings { + final String headerTitle; + final String generatedLabel; + final String totalLabel; + final String emptyMessage; + + const LogExportStrings({ + required this.headerTitle, + required this.generatedLabel, + required this.totalLabel, + required this.emptyMessage, + }); +} + +class LoggerExportService { + static String _generateFilename() { + final now = DateTime.now(); + final timestamp = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}_' + '${now.hour.toString().padLeft(2, '0')}-${now.minute.toString().padLeft(2, '0')}-${now.second.toString().padLeft(2, '0')}'; + return 'mostro_logs_$timestamp.txt'; + } + + static String _logsToText(List logs, LogExportStrings strings) { + if (logs.isEmpty) return '${strings.emptyMessage}\n'; + + final buffer = StringBuffer(); + buffer.writeln(strings.headerTitle); + buffer.writeln('${strings.generatedLabel}: ${DateTime.now()}'); + buffer.writeln('${strings.totalLabel}: ${logs.length}'); + buffer.writeln('${'=' * 60}\n'); + + for (final log in logs) { + buffer.writeln(log.format()); + } + + return buffer.toString(); + } + + static Future exportLogsToFolder( + List logs, + LogExportStrings strings, + ) async { + final filename = _generateFilename(); + final content = _logsToText(logs, strings); + final bytes = Uint8List.fromList(utf8.encode(content)); + + final result = await FilePicker.platform.saveFile( + dialogTitle: 'Save Logs', + fileName: filename, + type: FileType.custom, + allowedExtensions: ['txt'], + bytes: bytes, + ); + + return result; + } + + static Future exportLogsForSharing( + List logs, + LogExportStrings strings, + ) async { + final tempDir = await getTemporaryDirectory(); + final filename = _generateFilename(); + final filePath = p.join(tempDir.path, filename); + final file = File(filePath); + + final content = _logsToText(logs, strings); + await file.writeAsString(content); + + return file; + } + + static Future shareLogs( + File file, { + required String subject, + required String text, + }) async { + final xFile = XFile(file.path); + await Share.shareXFiles( + [xFile], + subject: subject, + text: text, + ); + } +}