485 lines
14 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|