// lib/widgets/activity/activity_files_page.dart import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:aves/model/activity/activity_track.dart'; import 'package:aves/utils/activity_file_parser.dart'; import 'package:aves/widgets/activity/activity_map_page.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart' show Distance, LatLng, LengthUnit; class ActivityFilesPage extends StatefulWidget { static const routeName = '/activity'; const ActivityFilesPage({super.key}); @override State createState() => _ActivityFilesPageState(); } class _ActivityFilesPageState extends State { static final Distance _distance = Distance(); final List _activities = []; 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 _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), ), ); } void _showMessage(String message) { ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( content: Text(message), ), ); } void _showRecalculateDialog(ActivityTrack activity) { final fileDistance = activity.fileDistanceMeters ?? activity.distanceMeters; final fileElevation = activity.fileElevationGainMeters ?? activity.elevationGainMeters; final recomputedDistance = _computeDistanceFromTrack(activity); final recomputedElevation = activity.computedElevationGainMeters ?? _readDiagnosticDouble(activity.diagnostics, 'computedElevationGainMeters'); showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Confronto ricalcolo'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(activity.displayName), const SizedBox(height: 12), _CompareLine( label: 'Distanza file', value: _formatDistance(fileDistance), ), _CompareLine( label: 'Distanza ricalcolata', value: _formatDistance(recomputedDistance), ), _CompareLine( label: 'Differenza distanza', value: _formatDistanceDelta(recomputedDistance, fileDistance), ), const Divider(height: 24), _CompareLine( label: 'D+ file', value: _formatElevation(fileElevation), ), _CompareLine( label: 'D+ ricalcolato', value: recomputedElevation == null ? 'non disponibile dai punti salvati' : _formatElevation(recomputedElevation), ), _CompareLine( label: 'Differenza D+', value: recomputedElevation == null ? '-' : _formatElevationDelta(recomputedElevation, fileElevation), ), if (recomputedElevation == null) ...[ const SizedBox(height: 12), const Text( 'Nota: per ricalcolare il D+ servono le quote dei singoli punti. ' 'Con il modello attuale la lista contiene solo LatLng; per FIT uso il valore calcolato dal parser quando disponibile.', style: TextStyle(fontSize: 12), ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Chiudi'), ), ], ); }, ); } static double? _computeDistanceFromTrack(ActivityTrack activity) { final points = activity.validPoints; if (points.length < 2) return null; double total = 0; for (int i = 1; i < points.length; i++) { final segment = _distance.as( LengthUnit.Meter, points[i - 1], points[i], ); if (!segment.isFinite || segment <= 0) continue; // Filtro semplice anti-spike per ricalcolo visuale nella lista. // Il parser FIT usa un algoritmo piu completo con timestamp/sport. if (segment > 500) continue; total += segment; } return total > 0 ? total : null; } static double? _readDiagnosticDouble(String? diagnostics, String key) { if (diagnostics == null || diagnostics.isEmpty) return null; for (final line in diagnostics.split('\n')) { final index = line.indexOf('='); if (index <= 0) continue; final currentKey = line.substring(0, index).trim(); if (currentKey != key) continue; final value = line.substring(index + 1).trim(); if (value == 'null' || value.isEmpty) return null; return double.tryParse(value.replaceAll(',', '.')); } return null; } static String _formatDistance(double? meters) { if (meters == null) return '-'; return '${(meters / 1000).toStringAsFixed(2)} km'; } static String _formatDistanceDelta(double? computed, double? file) { if (computed == null || file == null) return '-'; final delta = computed - file; final sign = delta >= 0 ? '+' : ''; return '$sign${(delta / 1000).toStringAsFixed(2)} km'; } static String _formatElevation(double? meters) { if (meters == null) return '-'; return '${meters.round()} m'; } static String _formatElevationDelta(double? computed, double? file) { if (computed == null || file == null) return '-'; final delta = computed - file; final sign = delta >= 0 ? '+' : ''; return '$sign${delta.round()} m'; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Attività'), actions: [ IconButton( icon: const Icon(Icons.add), tooltip: 'Apri file attività', onPressed: _pickActivityFile, ), ], ), body: _activities.isEmpty ? Center( child: TextButton.icon( onPressed: _pickActivityFile, icon: const Icon(Icons.upload_file), label: const Text('Apri file GPX, TCX o FIT'), ), ) : ListView.separated( itemCount: _activities.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final activity = _activities[index]; return ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), leading: ActivityTrackPreview( activity: activity, size: 64, ), title: Text( activity.displayName, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(activity.dateLabel), const SizedBox(height: 2), Text( [ activity.sportLabel, activity.distanceLabel, activity.elevationGainLabel, activity.durationLabel, '${activity.pointCount} punti', ].join(' · '), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), trailing: IconButton( icon: const Icon(Icons.calculate_outlined), tooltip: 'Ricalcola e confronta', onPressed: () => _showRecalculateDialog(activity), ), onTap: () => _openActivity(activity), ); }, ), ); } } class ActivityTrackPreview extends StatelessWidget { final ActivityTrack activity; final double size; const ActivityTrackPreview({ super.key, required this.activity, this.size = 64, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return ClipRRect( borderRadius: BorderRadius.circular(12), child: DecoratedBox( decoration: BoxDecoration( color: scheme.surfaceContainerHighest, border: Border.all( color: scheme.outlineVariant, ), ), child: SizedBox.square( dimension: size, child: CustomPaint( painter: _ActivityTrackPreviewPainter( tracks: activity.tracks, lineColor: scheme.primary, pointColor: scheme.secondary, ), ), ), ), ); } } class _ActivityTrackPreviewPainter extends CustomPainter { final Set> tracks; final Color lineColor; final Color pointColor; const _ActivityTrackPreviewPainter({ required this.tracks, required this.lineColor, required this.pointColor, }); @override void paint(Canvas canvas, Size size) { final allPoints = tracks .expand((segment) => segment) .where( (point) => point.latitude.isFinite && point.longitude.isFinite && point.latitude.abs() <= 90 && point.longitude.abs() <= 180, ) .toList(); if (allPoints.isEmpty) { _drawEmpty(canvas, size); return; } final minLat = allPoints.map((p) => p.latitude).reduce((a, b) => a < b ? a : b); final maxLat = allPoints.map((p) => p.latitude).reduce((a, b) => a > b ? a : b); final minLon = allPoints.map((p) => p.longitude).reduce((a, b) => a < b ? a : b); final maxLon = allPoints.map((p) => p.longitude).reduce((a, b) => a > b ? a : b); final latSpan = math.max(0.000001, maxLat - minLat); final lonSpan = math.max(0.000001, maxLon - minLon); const padding = 8.0; final drawableWidth = math.max(1.0, size.width - padding * 2); final drawableHeight = math.max(1.0, size.height - padding * 2); final scale = math.min(drawableWidth / lonSpan, drawableHeight / latSpan); final usedWidth = lonSpan * scale; final usedHeight = latSpan * scale; final offsetX = padding + (drawableWidth - usedWidth) / 2; final offsetY = padding + (drawableHeight - usedHeight) / 2; Offset project(LatLng point) { final x = offsetX + (point.longitude - minLon) * scale; final y = offsetY + (maxLat - point.latitude) * scale; return Offset(x, y); } final paint = Paint() ..color = lineColor ..strokeWidth = 2.2 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; for (final segment in tracks) { final validSegment = segment .where( (point) => point.latitude.isFinite && point.longitude.isFinite && point.latitude.abs() <= 90 && point.longitude.abs() <= 180, ) .toList(); if (validSegment.length < 2) continue; final path = Path()..moveTo(project(validSegment.first).dx, project(validSegment.first).dy); for (int i = 1; i < validSegment.length; i++) { final projected = project(validSegment[i]); path.lineTo(projected.dx, projected.dy); } canvas.drawPath(path, paint); } final start = project(allPoints.first); canvas.drawCircle( start, 3.2, Paint()..color = pointColor, ); } void _drawEmpty(Canvas canvas, Size size) { final paint = Paint() ..color = lineColor.withOpacity(0.4) ..strokeWidth = 1.5 ..style = PaintingStyle.stroke; canvas.drawLine( Offset(size.width * 0.25, size.height * 0.50), Offset(size.width * 0.75, size.height * 0.50), paint, ); } @override bool shouldRepaint(covariant _ActivityTrackPreviewPainter oldDelegate) { return oldDelegate.tracks != tracks || oldDelegate.lineColor != lineColor || oldDelegate.pointColor != pointColor; } } class _CompareLine extends StatelessWidget { final String label; final String value; const _CompareLine({ required this.label, required this.value, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(label)), const SizedBox(width: 16), Text( value, style: const TextStyle(fontWeight: FontWeight.w600), ), ], ), ); } }