// lib/utils/gpx_activity_parser.dart import 'package:aves/model/activity/activity_track.dart'; import 'package:gpx/gpx.dart'; import 'package:latlong2/latlong.dart'; import 'gps_smoothing.dart'; class GpxActivityParser { static final Distance _distance = Distance(); static ActivityTrack parse(String xml, {required String fileName}) { final gpx = GpxReader().fromString(xml); final points = <_GpxPoint>[]; final displayTracks = >{}; for (final trk in gpx.trks) { for (final seg in trk.trksegs) { final segmentPoints = <_GpxPoint>[]; for (final p in seg.trkpts) { final lat = p.lat; final lon = p.lon; if (lat == null || lon == null) continue; if (!lat.isFinite || !lon.isFinite) continue; if (lat.abs() > 90 || lon.abs() > 180) continue; final point = _GpxPoint( latLng: LatLng(lat, lon), elevationMeters: p.ele, time: p.time?.toUtc(), ); points.add(point); segmentPoints.add(point); } if (segmentPoints.isNotEmpty) { final latLngs = segmentPoints.map((p) => p.latLng).toList(); final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0); displayTracks.add(smoothed); } } } points.sort(_compareByTimeKeepingNullsLast); final computedDistanceMeters = _computeDistanceFromPoints(points); final computedElevationGainMeters = _computeElevationLikeCommercialApps(points); final computedDuration = _computeDuration(points); final computedStartTime = _computeStartTime(points); final routeName = _readRouteName(gpx, fileName); return ActivityTrack( name: fileName, format: 'GPX', tracks: displayTracks, routeName: routeName, pointCount: points.length, distanceMeters: computedDistanceMeters, elevationGainMeters: computedElevationGainMeters, fileDistanceMeters: null, fileElevationGainMeters: null, computedDistanceMeters: computedDistanceMeters, computedElevationGainMeters: computedElevationGainMeters, duration: computedDuration, startTime: computedStartTime, sport: null, diagnostics: [ 'computedDistanceMeters=$computedDistanceMeters', 'computedElevationGainMeters=$computedElevationGainMeters', 'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}', 'elevationMin=${_minElevation(points)}', 'elevationMax=${_maxElevation(points)}', 'originalPoints=${points.length}', 'displayPoints=${displayTracks.expand((segment) => segment).length}', 'routeName=$routeName', ].join('\n'), ); } static String? _readRouteName(Gpx gpx, String fileName) { // Nome GPX standard: primo valido. for (final trk in gpx.trks) { final name = trk.name?.trim(); if (name != null && name.isNotEmpty) return name; } // Alcuni GPX usano invece di . for (final rte in gpx.rtes) { final name = rte.name?.trim(); if (name != null && name.isNotEmpty) return name; } // Fallback: nome file senza estensione. final cleaned = fileName.trim(); if (cleaned.isEmpty) return null; final dotIndex = cleaned.lastIndexOf('.'); if (dotIndex > 0) return cleaned.substring(0, dotIndex); return cleaned; } static double? _computeDistanceFromPoints(List<_GpxPoint> points) { if (points.length < 2) return null; double dist = 0; for (int i = 1; i < points.length; i++) { final prev = points[i - 1]; final curr = points[i]; final seg = _distance.as( LengthUnit.Meter, prev.latLng, curr.latLng, ); if (!seg.isFinite || seg <= 0) continue; final dt = prev.time != null && curr.time != null ? curr.time!.difference(prev.time!).inSeconds.abs() : null; if (dt == null || dt == 0) { // Filtro prudente anti-spike quando manca il timestamp. if (seg > 500) continue; dist += seg; continue; } if (seg < 0.3) continue; final speedMps = seg / dt; if (speedMps > 30) continue; dist += seg; } return dist > 0 ? dist : null; } /// Fallback stile app commerciali: /// - rimozione quote impossibili; /// - smoothing mediano; /// - rimozione spike verticali; /// - somma delle salite valle-picco sopra soglia. /// /// Non fa quota finale - quota iniziale, quindi i giri circolari non si annullano. static double? _computeElevationLikeCommercialApps(List<_GpxPoint> points) { final elevations = points .map((p) => p.elevationMeters) .whereType() .where((e) => e.isFinite && e > -500 && e < 9000) .toList(); if (elevations.length < 2) return null; final smoothed = _medianSmooth(elevations, radius: 2); const minClimbMeters = 2.0; const reverseThresholdMeters = 2.0; const maxVerticalJumpMeters = 80.0; double gain = 0; double valley = smoothed.first; double peak = smoothed.first; double previous = smoothed.first; for (int i = 1; i < smoothed.length; i++) { final current = smoothed[i]; final delta = current - previous; if (delta.abs() > maxVerticalJumpMeters) { previous = current; valley = current; peak = current; continue; } if (current > peak) { peak = current; } if (current < valley) { valley = current; peak = current; } if (peak - current >= reverseThresholdMeters) { final climb = peak - valley; if (climb >= minClimbMeters) { gain += climb; } valley = current; peak = current; } previous = current; } final finalClimb = peak - valley; if (finalClimb >= minClimbMeters) { gain += finalClimb; } return gain > 0 ? gain : null; } static List _medianSmooth(List values, {required int radius}) { if (values.length < 3 || radius <= 0) return List.from(values); final result = []; for (int i = 0; i < values.length; i++) { final start = i - radius < 0 ? 0 : i - radius; final end = i + radius >= values.length ? values.length - 1 : i + radius; final window = []; for (int j = start; j <= end; j++) { window.add(values[j]); } window.sort(); result.add(window[window.length ~/ 2]); } return result; } static Duration? _computeDuration(List<_GpxPoint> points) { final times = points.map((p) => p.time).nonNulls.toList(); if (times.length < 2) return null; times.sort(); final duration = times.last.difference(times.first); return duration.isNegative ? null : duration; } static DateTime? _computeStartTime(List<_GpxPoint> points) { final times = points.map((p) => p.time).nonNulls.toList(); if (times.isEmpty) return null; times.sort(); return times.first; } static double? _minElevation(List<_GpxPoint> points) { final values = points .map((p) => p.elevationMeters) .whereType() .where((e) => e.isFinite) .toList(); if (values.isEmpty) return null; values.sort(); return values.first; } static double? _maxElevation(List<_GpxPoint> points) { final values = points .map((p) => p.elevationMeters) .whereType() .where((e) => e.isFinite) .toList(); if (values.isEmpty) return null; values.sort(); return values.last; } static int _compareByTimeKeepingNullsLast(_GpxPoint a, _GpxPoint b) { final at = a.time; final bt = b.time; if (at == null && bt == null) return 0; if (at == null) return 1; if (bt == null) return -1; return at.compareTo(bt); } } class _GpxPoint { final LatLng latLng; final double? elevationMeters; final DateTime? time; const _GpxPoint({ required this.latLng, this.elevationMeters, this.time, }); }