// lib/widgets/activity/activity_files_page.dart import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:aves/model/activity/activity_entry.dart'; import 'package:aves/model/activity/activity_preview_point.dart'; import 'package:aves/model/activity/activity_scan_service.dart'; import 'package:aves/model/activity/activity_track.dart'; import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/activity_file_parser.dart'; import 'package:aves/widgets/activity/activity_auto_sync_controller.dart'; import 'package:aves/widgets/activity/activity_list_tile.dart'; import 'package:aves/widgets/activity/activity_map_page.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar/notifications.dart'; import 'package:aves/widgets/common/dialogs/miui_safe_dialog.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:aves/widgets/activity/utils/activity_formatters.dart'; import 'package:aves/widgets/activity/activity_sport_groups_view.dart'; import 'package:aves/widgets/activity/activity_album_groups_view.dart'; import 'package:aves/model/activity/activity_grouping.dart'; import 'package:aves/widgets/activity/dialogs/activity_db_fields_dialog.dart'; import 'package:aves/widgets/activity/dialogs/activity_recalculate_dialog.dart'; import 'package:aves/widgets/activity/activity_data_loader.dart'; enum ActivityViewMode { activities, sports, albums, } class ActivityFilesPage extends StatefulWidget { static const routeName = '/activity'; const ActivityFilesPage({super.key}); @override State createState() => _ActivityFilesPageState(); } class _ActivityFilesPageState extends State with WidgetsBindingObserver { // File aperti manualmente con il pulsante "+" final List _activities = []; // Attività salvate nel DB dopo scansione final List _dbActivities = []; final Map> _dbPreviewPoints = {}; String? _activityTreeUri; bool _loadingDbActivities = false; late final ActivityAutoSyncController _autoSyncController; bool _activityAutoScanRunning = false; final StreamController _navBarEvents = StreamController.broadcast(); int _activityNavIndex = 0; ActivityViewMode _activityViewMode = ActivityViewMode.activities; bool _activityFavouriteOnly = false; bool _activityWithoutSportOnly = false; String? _activitySportFilter; String? _activityDirectoryFilter; final List _sportGroups = []; final List _albumGroups = []; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _activityTreeUri = settings.activityRootTreeUri; _autoSyncController = ActivityAutoSyncController( onScanRequested: _autoScanActivityLibrary, )..start(); _loadDbActivities(); // Prima sincronizzazione silenziosa quando la pagina si apre. _autoSyncController.schedule( reason: 'page_open', delay: const Duration(milliseconds: 800), ); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _autoSyncController.dispose(); _navBarEvents.close(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _autoSyncController.schedule( reason: 'app_resumed', delay: const Duration(milliseconds: 1200), ); } } void _onActivityNavSelected(int index) { switch (index) { case 0: setState(() { _activityViewMode = ActivityViewMode.activities; _activityNavIndex = 0; _activityFavouriteOnly = false; _activityWithoutSportOnly = false; _activitySportFilter = null; _activityDirectoryFilter = null; }); _loadDbActivities(); break; case 1: setState(() { _activityViewMode = ActivityViewMode.sports; _activityNavIndex = 1; _activityFavouriteOnly = false; _activityWithoutSportOnly = false; _activitySportFilter = null; _activityDirectoryFilter = null; }); _loadSportGroups(); break; case 2: setState(() { _activityViewMode = ActivityViewMode.activities; _activityNavIndex = 2; _activityFavouriteOnly = true; _activityWithoutSportOnly = false; _activitySportFilter = null; _activityDirectoryFilter = null; }); _loadDbActivities(); break; case 3: setState(() { _activityViewMode = ActivityViewMode.albums; _activityNavIndex = 3; _activityFavouriteOnly = false; _activityWithoutSportOnly = false; _activitySportFilter = null; _activityDirectoryFilter = null; }); _loadAlbumGroups(); break; } } String _emptyActivityMessage() { if (_activityFavouriteOnly) { return 'Nessuna attività preferita.'; } if (_activityWithoutSportOnly || _activitySportFilter != null) { return 'Nessuna attività per questo sport.'; } if (_activityDirectoryFilter != null) { return 'Nessuna attività in questo album.'; } return 'Nessuna attività trovata.'; } Future _toggleActivityFavorite(ActivityEntry entry) async { final id = entry.id; if (id == null) { _showMessage('Impossibile modificare preferito: id attività mancante.'); return; } final updated = entry.copyWith( isFavorite: !entry.isFavorite, dateModifiedMillis: DateTime.now().millisecondsSinceEpoch, ); await localMediaDb.updateActivity(id, updated); if (!mounted) return; _showMessage( updated.isFavorite ? 'Attività aggiunta ai preferiti.' : 'Attività rimossa dai preferiti.', ); await _loadDbActivities(); } Future _renameActivity(ActivityEntry entry) async { final id = entry.id; if (id == null) { _showMessage('Impossibile rinominare: id attività mancante.'); return; } final newTitle = await showMiuiSafeDialog( context: context, builder: (dialogContext) { return MiuiSafeTextDialog( title: 'Modifica nome attività', label: 'Nome attività', hint: 'Inserisci un nome personalizzato', initialText: entry.bestName, ); }, ); if (newTitle == null) return; final normalizedTitle = newTitle.trim(); final updated = entry.copyWith( title: normalizedTitle.isEmpty ? null : normalizedTitle, dateModifiedMillis: DateTime.now().millisecondsSinceEpoch, ); await localMediaDb.updateActivity(id, updated); if (!mounted) return; setState(() { final index = _dbActivities.indexWhere((e) => e.id == id); if (index != -1) { _dbActivities[index] = updated; } }); _showMessage( normalizedTitle.isEmpty ? 'Nome personalizzato rimosso.' : 'Nome attività aggiornato.', ); } Future _loadDbActivities() async { if (_loadingDbActivities) return; setState(() { _loadingDbActivities = true; }); try { final result = await ActivityDataLoader.loadEntries( favouriteOnly: _activityFavouriteOnly, withoutSportOnly: _activityWithoutSportOnly, sport: _activitySportFilter, directory: _activityDirectoryFilter, ); if (!mounted) return; setState(() { _dbActivities ..clear() ..addAll(result.entries); _dbPreviewPoints ..clear() ..addAll(result.previewPointsByActivityId); _loadingDbActivities = false; }); } catch (e) { if (!mounted) return; setState(() { _loadingDbActivities = false; }); _showMessage('Errore caricamento attività DB: $e'); } } Future _loadSportGroups() async { if (_loadingDbActivities) return; setState(() { _loadingDbActivities = true; }); try { final result = await ActivityDataLoader.loadSportGroups(); if (!mounted) return; setState(() { _sportGroups ..clear() ..addAll(result.groups); _dbPreviewPoints ..clear() ..addAll(result.previewPointsByActivityId); _loadingDbActivities = false; }); } catch (e) { if (!mounted) return; setState(() { _loadingDbActivities = false; }); _showMessage('Errore caricamento sport attività: $e'); } } Future _openSportGroup(ActivitySportGroup group) async { setState(() { _activityViewMode = ActivityViewMode.activities; _activityNavIndex = 1; _activityFavouriteOnly = false; _activityWithoutSportOnly = group.withoutSport; _activitySportFilter = group.sportFilter; _activityDirectoryFilter = null; }); await _loadDbActivities(); } Future _loadAlbumGroups() async { if (_loadingDbActivities) return; setState(() { _loadingDbActivities = true; }); try { final result = await ActivityDataLoader.loadAlbumGroups(); if (!mounted) return; setState(() { _albumGroups ..clear() ..addAll(result.groups); _dbPreviewPoints ..clear() ..addAll(result.previewPointsByActivityId); _loadingDbActivities = false; }); } catch (e) { if (!mounted) return; setState(() { _loadingDbActivities = false; }); _showMessage('Errore caricamento album attività: $e'); } } Future _openAlbumGroup(ActivityAlbumGroup group) async { setState(() { _activityViewMode = ActivityViewMode.activities; _activityNavIndex = 3; _activityFavouriteOnly = false; _activityWithoutSportOnly = false; _activitySportFilter = null; _activityDirectoryFilter = group.directoryFilter; }); await _loadDbActivities(); } Future _pickActivityFile() async { try { final result = await FilePicker.platform.pickFiles( type: FileType.any, withData: true, ); final pickedFile = result?.files.single; if (pickedFile == null) return; if (!ActivityFileParser.isSupportedFileName(pickedFile.name)) { if (!mounted) return; _showMessage('Seleziona un file GPX, TCX o FIT.'); return; } final bytes = await _readPickedFileBytes(pickedFile); final activity = await ActivityFileParser.parse( bytes, fileName: pickedFile.name, ); if (activity == null) { if (!mounted) return; _showMessage('Impossibile leggere il file attività.'); return; } if (activity.isEmpty) { if (!mounted) return; _showMessage('Nessuna traccia GPS valida trovata nel file.'); return; } if (!mounted) return; setState(() { _activities.add(activity); }); } catch (e) { if (!mounted) return; _showMessage('Impossibile leggere il file attività: $e'); } } Future _pickAndSaveActivityDirectory() async { final picked = await storageService.pickActivityDirectory(); if (!mounted) return null; if (picked == null || picked.treeUri.isEmpty) { _showMessage('Cartella attività non selezionata.'); return null; } final selectedPath = picked.path; const expectedPath = SettingsDefaults.activityRootPath; if (!ActivityFormatters.isExpectedActivityRoot(selectedPath, expectedPath)) { await _showActivityPermissionDialog( title: 'Cartella non corretta', message: 'Hai selezionato una cartella diversa da quella richiesta.\n\n' 'Cartella selezionata:\n' '${selectedPath == null || selectedPath.isEmpty ? 'non riconosciuta' : selectedPath}\n\n' 'Cartella richiesta:\n' '$expectedPath\n\n' 'Devi selezionare esattamente la cartella "Activity" nella memoria interna del dispositivo.\n\n' 'Non selezionare "Activity/main", "Download", "Documents" o altre cartelle.', ); return null; } settings.activityRootPath = expectedPath; settings.activityRootTreeUri = picked.treeUri; settings.activityRootPromptDone = true; final mainReady = await storageService.ensureActivityMainDirectory( picked.treeUri, ); if (!mounted) return picked.treeUri; if (!mainReady) { await _showActivityPermissionDialog( title: 'Sottocartella main non creata', message: 'Aves ha ottenuto l’accesso alla cartella Activity, ma non è riuscita ' 'a creare automaticamente la sottocartella:\n\n' '$expectedPath/main\n\n' 'Puoi crearla manualmente con un file manager.\n\n' 'La scansione continuerà comunque a funzionare sulla cartella Activity ' 'e sulle sue sottocartelle esistenti.', ); } setState(() { _activityTreeUri = picked.treeUri; }); return picked.treeUri; } Future _pickActivityDirectory() async { try { final treeUri = await _pickAndSaveActivityDirectory(); if (!mounted) return; if (treeUri == null || treeUri.isEmpty) { return; } _showMessage('Cartella attività selezionata e salvata.'); _autoSyncController.schedule( reason: 'directory_selected', delay: const Duration(milliseconds: 500), ); } catch (e) { if (!mounted) return; await _showActivityPermissionDialog( title: 'Errore selezione cartella', message: 'Impossibile selezionare la cartella attività:\n\n$e', ); } } Future _scanActivityLibrary() async { try { var treeUri = settings.activityRootTreeUri ?? _activityTreeUri; debugPrint( '[ActivityPage] settings treeUri=${settings.activityRootTreeUri}', ); debugPrint('[ActivityPage] local treeUri=$_activityTreeUri'); if (treeUri == null || treeUri.isEmpty) { treeUri = await _pickAndSaveActivityDirectory(); if (treeUri == null || treeUri.isEmpty) { if (!mounted) return; await _showActivityPermissionDialog( title: 'Cartella non selezionata', message: 'Per scansionare le attività devi selezionare la cartella:\n\n' '${SettingsDefaults.activityRootPath}\n\n' 'Questa cartella sarà salvata nei settings.', ); return; } } final scanner = ActivityScanService( db: localMediaDb, ); final result = await scanner.scanSaf(treeUri); if (!mounted) return; if (result.scannedFiles == 0) { final files = await storageService.listActivityFiles(treeUri); await _showActivityPermissionDialog( title: 'Nessun file attività trovato', message: 'Aves ha accesso alla cartella selezionata, ma non ha trovato file FIT, GPX o TCX.\n\n' 'File attività trovati dal layer Android: ${files.length}\n\n' 'Cartella prevista:\n' '${SettingsDefaults.activityRootPath}', ); return; } _showMessage( 'Scan completato: ' '${result.scannedFiles} file, ' '${result.inserted} nuovi, ' '${result.updated} aggiornati, ' '${result.skippedUnchanged} invariati, ' '${result.errors.length} errori.', ); await _loadDbActivities(); } catch (e) { if (!mounted) return; await _showActivityPermissionDialog( title: 'Errore scan attività', message: 'Errore durante la scansione attività:\n\n$e', ); } } Future _autoScanActivityLibrary(String reason) async { if (_activityAutoScanRunning) { debugPrint( '[ActivityPage] auto scan skipped, already running, reason=$reason', ); return; } final treeUri = settings.activityRootTreeUri ?? _activityTreeUri; if (treeUri == null || treeUri.isEmpty) { debugPrint('[ActivityPage] auto scan skipped, no treeUri, reason=$reason'); return; } _activityAutoScanRunning = true; try { debugPrint('[ActivityPage] auto scan started, reason=$reason'); final scanner = ActivityScanService( db: localMediaDb, ); final result = await scanner.scanSaf(treeUri); debugPrint( '[ActivityPage] auto scan completed, reason=$reason, ' 'scanned=${result.scannedFiles}, ' 'inserted=${result.inserted}, ' 'updated=${result.updated}, ' 'skipped=${result.skippedUnchanged}, ' 'errors=${result.errors.length}', ); if (!mounted) return; await _loadDbActivities(); } catch (e, st) { debugPrint('[ActivityPage] auto scan failed, reason=$reason, error=$e'); debugPrint('[ActivityPage] auto scan stack=$st'); } finally { _activityAutoScanRunning = false; } } Future _readPickedFileBytes(PlatformFile pickedFile) async { final bytes = pickedFile.bytes; if (bytes != null) return bytes; final path = pickedFile.path; if (path == null) { throw Exception('percorso file non disponibile'); } return File(path).readAsBytes(); } void _openActivity(ActivityTrack activity) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => ActivityMapPage(activity: activity), ), ); } Future _openDbActivity(ActivityEntry entry) async { try { final bytes = await storageService.readActivityFile(entry.uri); if (bytes.isEmpty) { if (!mounted) return; _showMessage('Impossibile leggere il file attività.'); return; } final activity = await ActivityFileParser.parse( bytes, fileName: entry.fileName, ); if (activity == null || activity.isEmpty) { if (!mounted) return; _showMessage('Nessuna traccia GPS valida trovata nel file.'); return; } if (!mounted) return; _openActivity(activity); } catch (e) { if (!mounted) return; _showMessage('Errore apertura attività: $e'); } } Future _showDbRecalculateDialog(ActivityEntry entry) async { try { final bytes = await storageService.readActivityFile(entry.uri); if (bytes.isEmpty) { if (!mounted) return; _showMessage('Impossibile leggere il file attività.'); return; } final activity = await ActivityFileParser.parse( bytes, fileName: entry.fileName, ); if (activity == null || activity.isEmpty) { if (!mounted) return; _showMessage('Nessuna traccia GPS valida trovata nel file.'); return; } if (!mounted) return; showActivityRecalculateDialog( context: context, activity: activity, ); } catch (e) { if (!mounted) return; await _showActivityPermissionDialog( title: 'Errore ricalcolo attività', message: 'Impossibile ricalcolare l’attività:\n\n$e', ); } } void _showMessage(String message) { ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( content: Text(message), ), ); } Future _showActivityPermissionDialog({ required String title, required String message, }) async { if (!mounted) return; await showDialog( context: context, builder: (context) { return AlertDialog( title: Text(title), content: SingleChildScrollView( child: Text(message), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ); }, ); } Widget _buildActivityListBody() { if (_dbActivities.isEmpty && _activities.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(_emptyActivityMessage()), const SizedBox(height: 12), TextButton.icon( onPressed: _pickActivityFile, icon: const Icon(Icons.upload_file), label: const Text('Apri file GPX, TCX o FIT'), ), const SizedBox(height: 8), TextButton.icon( onPressed: _pickActivityDirectory, icon: const Icon(Icons.folder_open), label: const Text('Scegli cartella attività'), ), const SizedBox(height: 8), TextButton.icon( onPressed: _scanActivityLibrary, icon: const Icon(Icons.sync), label: const Text('Scansiona attività'), ), ], ), ); } return RefreshIndicator( onRefresh: _loadDbActivities, child: ListView.separated( itemCount: _dbActivities.length + _activities.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { if (index < _dbActivities.length) { final entry = _dbActivities[index]; final points = entry.id == null ? const [] : _dbPreviewPoints[entry.id!] ?? const []; return ActivityEntryListTile( entry: entry, points: points, dateLabel: ActivityFormatters.entryDate(entry), distanceLabel: ActivityFormatters.distance(entry.distanceMeters), elevationLabel: ActivityFormatters.entryElevation(entry.elevationGainMeters), durationLabel: ActivityFormatters.entryDuration(entry.durationMillis), onTap: () => _openDbActivity(entry), onToggleFavorite: () => _toggleActivityFavorite(entry), onRename: () => _renameActivity(entry), onRecalculate: () => _showDbRecalculateDialog(entry), onInfo: () => showActivityDbFieldsDialog( context: context, entry: entry, ), ); } final manualIndex = index - _dbActivities.length; final activity = _activities[manualIndex]; return ActivityTrackListTile( activity: activity, onTap: () => _openActivity(activity), onRecalculate: () => showActivityRecalculateDialog( context: context, activity: activity, ), ); }, ), ); } Widget _buildBody() { if (_loadingDbActivities) { return const Center( child: CircularProgressIndicator(), ); } switch (_activityViewMode) { case ActivityViewMode.activities: return _buildActivityListBody(); case ActivityViewMode.sports: return ActivitySportGroupsView( groups: _sportGroups, previewPointsByActivityId: _dbPreviewPoints, onRefresh: _loadSportGroups, onTap: _openSportGroup, ); case ActivityViewMode.albums: return ActivityAlbumGroupsView( groups: _albumGroups, previewPointsByActivityId: _dbPreviewPoints, onRefresh: _loadAlbumGroups, onTap: _openAlbumGroup, ); } } @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( title: const Text('Attività'), actions: [ IconButton( icon: const Icon(Icons.folder_open), tooltip: 'Scegli cartella attività', onPressed: _pickActivityDirectory, ), IconButton( icon: const Icon(Icons.sync), tooltip: 'Scansiona attività', onPressed: _scanActivityLibrary, ), IconButton( icon: const Icon(Icons.add), tooltip: 'Apri file attività', onPressed: _pickActivityFile, ), ], ), bottomNavigationBar: AppBottomNavBar( events: _navBarEvents.stream, mode: AppBottomNavBarMode.activity, activityIndex: _activityNavIndex, onActivityIndexSelected: _onActivityNavSelected, ), body: _buildBody(), ); } }