Aves_mio0.88/lib/widgets/activity/activity_files_page.dart
2026-10-03 12:51:15 +02:00

888 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<ActivityFilesPage> createState() => _ActivityFilesPageState();
}
class _ActivityFilesPageState extends State<ActivityFilesPage> with WidgetsBindingObserver {
// File aperti manualmente con il pulsante "+"
final List<ActivityTrack> _activities = [];
// Attività salvate nel DB dopo scansione
final List<ActivityEntry> _dbActivities = [];
final Map<int, List<ActivityPreviewPoint>> _dbPreviewPoints = {};
String? _activityTreeUri;
bool _loadingDbActivities = false;
late final ActivityAutoSyncController _autoSyncController;
bool _activityAutoScanRunning = false;
final StreamController<DraggableScrollbarEvent> _navBarEvents = StreamController<DraggableScrollbarEvent>.broadcast();
int _activityNavIndex = 0;
ActivityViewMode _activityViewMode = ActivityViewMode.activities;
bool _activityFavouriteOnly = false;
bool _activityWithoutSportOnly = false;
String? _activitySportFilter;
String? _activityDirectoryFilter;
final List<ActivitySportGroup> _sportGroups = [];
final List<ActivityAlbumGroup> _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<void> _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<void> _renameActivity(ActivityEntry entry) async {
final id = entry.id;
if (id == null) {
_showMessage('Impossibile rinominare: id attività mancante.');
return;
}
final newTitle = await showMiuiSafeDialog<String>(
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<void> _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<void> _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<void> _openSportGroup(ActivitySportGroup group) async {
setState(() {
_activityViewMode = ActivityViewMode.activities;
_activityNavIndex = 1;
_activityFavouriteOnly = false;
_activityWithoutSportOnly = group.withoutSport;
_activitySportFilter = group.sportFilter;
_activityDirectoryFilter = null;
});
await _loadDbActivities();
}
Future<void> _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<void> _openAlbumGroup(ActivityAlbumGroup group) async {
setState(() {
_activityViewMode = ActivityViewMode.activities;
_activityNavIndex = 3;
_activityFavouriteOnly = false;
_activityWithoutSportOnly = false;
_activitySportFilter = null;
_activityDirectoryFilter = group.directoryFilter;
});
await _loadDbActivities();
}
Future<void> _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<String?> _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<void> _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<void> _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<void> _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<Uint8List> _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<void> _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<void> _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<void> _showActivityPermissionDialog({
required String title,
required String message,
}) async {
if (!mounted) return;
await showDialog<void>(
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 <ActivityPreviewPoint>[] : _dbPreviewPoints[entry.id!] ?? const <ActivityPreviewPoint>[];
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(),
);
}
}