aves_mio0.31/lib/widgets/activity/activity_files_page.dart
2026-07-18 13:39:22 +02:00

485 lines
14 KiB
Dart

// 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<ActivityFilesPage> createState() => _ActivityFilesPageState();
}
class _ActivityFilesPageState extends State<ActivityFilesPage> {
static final Distance _distance = Distance();
final List<ActivityTrack> _activities = [];
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<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),
),
);
}
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<void>(
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<List<LatLng>> 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),
),
],
),
);
}
}