// lib/utils/tcx_activity_parser.dart import 'package:aves/model/activity/activity_track.dart'; import 'package:latlong2/latlong.dart'; import 'package:xml/xml.dart'; import 'gps_smoothing.dart'; class TcxActivityParser { static final Distance _distance = Distance(); static ActivityTrack parse(String xml, {required String fileName}) { final doc = XmlDocument.parse(xml); final points = <_TcxPoint>[]; final displayTracks = >{}; final activityName = _readActivityName(doc, fileName); final sport = _readSport(doc); double? fileDistanceMeters; double? fileElevationGainMeters; // Nei TCX la struttura tipica e': // Activity -> Lap -> Track -> Trackpoint. // Mantengo un segmento display per ogni Track, cosi non unisco pezzi separati. for (final track in doc.findAllElements('Track')) { final segmentPoints = <_TcxPoint>[]; for (final tp in track.findElements('Trackpoint')) { final lat = _readDouble(tp, const [ 'LatitudeDegrees', ]); final lon = _readDouble(tp, const [ 'LongitudeDegrees', ]); final ele = _readDouble(tp, const [ 'AltitudeMeters', ]); final time = _readDateTime(tp, const [ 'Time', ]); final distance = _readDouble(tp, const [ 'DistanceMeters', ]); if (lat == null || lon == null) continue; if (!lat.isFinite || !lon.isFinite) continue; if (lat.abs() > 90 || lon.abs() > 180) continue; final point = _TcxPoint( latLng: LatLng(lat, lon), elevationMeters: ele, time: time, distanceMeters: distance, ); 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); } } // Fallback per TCX non standard: se non esistono , leggo tutti i // Trackpoint globali in un unico segmento. if (points.isEmpty) { final segmentPoints = <_TcxPoint>[]; for (final tp in doc.findAllElements('Trackpoint')) { final lat = _readDouble(tp, const ['LatitudeDegrees']); final lon = _readDouble(tp, const ['LongitudeDegrees']); final ele = _readDouble(tp, const ['AltitudeMeters']); final time = _readDateTime(tp, const ['Time']); final distance = _readDouble(tp, const ['DistanceMeters']); if (lat == null || lon == null) continue; if (!lat.isFinite || !lon.isFinite) continue; if (lat.abs() > 90 || lon.abs() > 180) continue; final point = _TcxPoint( latLng: LatLng(lat, lon), elevationMeters: ele, time: time, distanceMeters: distance, ); 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); fileDistanceMeters = _readFileDistanceMeters(doc) ?? _distanceFromTrackpointField(points); fileElevationGainMeters = _readFileElevationGainMeters(doc); final computedDistanceMeters = _computeDistanceFromPoints(points); final computedElevationGainMeters = _computeElevationLikeCommercialApps(points); final computedDuration = _computeDuration(points); final computedStartTime = _computeStartTime(points); final finalDistanceMeters = _chooseDistanceMeters( fileDistanceMeters: fileDistanceMeters, computedDistanceMeters: computedDistanceMeters, ); final finalElevationGainMeters = _chooseElevationGainMeters( fileElevationGainMeters: fileElevationGainMeters, computedElevationGainMeters: computedElevationGainMeters, ); return ActivityTrack( name: fileName, format: 'TCX', tracks: displayTracks, routeName: activityName, pointCount: points.length, distanceMeters: finalDistanceMeters, elevationGainMeters: finalElevationGainMeters, fileDistanceMeters: fileDistanceMeters, fileElevationGainMeters: fileElevationGainMeters, computedDistanceMeters: computedDistanceMeters, computedElevationGainMeters: computedElevationGainMeters, duration: computedDuration, startTime: computedStartTime, sport: sport, diagnostics: [ 'fileDistanceMeters=$fileDistanceMeters', 'computedDistanceMeters=$computedDistanceMeters', 'finalDistanceMeters=$finalDistanceMeters', 'fileElevationGainMeters=$fileElevationGainMeters', 'computedElevationGainMeters=$computedElevationGainMeters', 'finalElevationGainMeters=$finalElevationGainMeters', 'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}', 'elevationMin=${_minElevation(points)}', 'elevationMax=${_maxElevation(points)}', 'originalPoints=${points.length}', 'displayPoints=${displayTracks.expand((segment) => segment).length}', 'routeName=$activityName', 'sport=$sport', ].join('\n'), ); } static String? _readActivityName(XmlDocument doc, String fileName) { // TCX spesso usa Activity/Id come data/identificativo. final id = doc .findAllElements('Activity') .expand((activity) => activity.findElements('Id')) .map((e) => e.text.trim()) .firstWhereOrNull((value) => value.isNotEmpty); if (id != null) return id; // Alcuni export inseriscono un nome in Name o Notes. final name = doc .findAllElements('Name') .map((e) => e.text.trim()) .firstWhereOrNull((value) => value.isNotEmpty); if (name != null) return name; final cleaned = fileName.trim(); if (cleaned.isEmpty) return null; final dotIndex = cleaned.lastIndexOf('.'); if (dotIndex > 0) return cleaned.substring(0, dotIndex); return cleaned; } static String? _readSport(XmlDocument doc) { final sport = doc .findAllElements('Activity') .map((e) => e.getAttribute('Sport')?.trim()) .whereType() .firstWhereOrNull((value) => value.isNotEmpty); return _normalizeSport(sport); } static double? _readFileDistanceMeters(XmlDocument doc) { // Nei TCX il dato aggregato normalmente si trova in Lap/DistanceMeters. final lapDistances = doc .findAllElements('Lap') .map((lap) => _readDirectDouble(lap, const ['DistanceMeters'])) .whereType() .where((v) => v.isFinite && v > 0) .toList(); if (lapDistances.isNotEmpty) { final total = lapDistances.fold(0, (sum, value) => sum + value); return _normalizeDistanceMeters(total); } return null; } static double? _readFileElevationGainMeters(XmlDocument doc) { // TCX standard Garmin non contiene sempre TotalAscent, ma alcuni export // custom lo aggiungono. Se c'e' ed e' plausibile, lo uso come valore file. final values = []; for (final name in const [ 'TotalAscent', 'TotalAscentMeters', 'TotalElevationGain', 'Ascent', ]) { values.addAll( doc .findAllElements(name) .map((e) => double.tryParse(e.text.trim().replaceAll(',', '.'))) .whereType() .where((v) => v.isFinite && v > 0), ); } if (values.isEmpty) return null; // Se sono presenti piu' lap/valori, sommo. Se e' un solo valore, torna quello. final total = values.fold(0, (sum, value) => sum + value); return _normalizeElevationGain(total); } static double? _distanceFromTrackpointField(List<_TcxPoint> points) { final values = points .map((p) => p.distanceMeters) .whereType() .where((v) => v.isFinite && v > 0) .toList(); if (values.length < 2) return null; values.sort(); final distance = values.last - values.first; return _normalizeDistanceMeters(distance > 0 ? distance : values.last); } static double? _chooseDistanceMeters({ required double? fileDistanceMeters, required double? computedDistanceMeters, }) { final file = _normalizeDistanceMeters(fileDistanceMeters); if (file != null) return file; return _normalizeDistanceMeters(computedDistanceMeters); } static double? _chooseElevationGainMeters({ required double? fileElevationGainMeters, required double? computedElevationGainMeters, }) { final file = _normalizeElevationGain(fileElevationGainMeters); if (file != null) return file; return _normalizeElevationGain(computedElevationGainMeters); } static double? _normalizeDistanceMeters(double? value) { if (value == null || !value.isFinite || value <= 0) return null; const maxPlausibleMeters = 500000.0; if (value > maxPlausibleMeters && value / 100 <= maxPlausibleMeters) { return value / 100; } if (value > maxPlausibleMeters) return null; return value; } static double? _normalizeElevationGain(double? value) { if (value == null || !value.isFinite || value <= 0) return null; if (value > 20000) return null; return value; } static double? _readDouble(XmlElement parent, List names) { for (final name in names) { final normalizedName = _normalizeXmlName(name); // Uso findAllElements perche' nei TCX lat/lon sono normalmente dentro: // Trackpoint -> Position -> LatitudeDegrees / LongitudeDegrees. for (final element in parent.findAllElements('*')) { if (_normalizeXmlName(element.name.local) == normalizedName) { final value = double.tryParse(element.text.trim().replaceAll(',', '.')); if (value != null) return value; } } } return null; } static double? _readDirectDouble(XmlElement parent, List names) { for (final name in names) { final normalizedName = _normalizeXmlName(name); for (final child in parent.childElements) { if (_normalizeXmlName(child.name.local) == normalizedName) { final value = double.tryParse(child.text.trim().replaceAll(',', '.')); if (value != null) return value; } } } return null; } static DateTime? _readDateTime(XmlElement parent, List names) { for (final name in names) { final normalizedName = _normalizeXmlName(name); for (final element in parent.findAllElements('*')) { if (_normalizeXmlName(element.name.local) == normalizedName) { final value = DateTime.tryParse(element.text.trim()); if (value != null) return value.toUtc(); } } } return null; } static String _normalizeXmlName(String value) { return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); } static double? _computeDistanceFromPoints(List<_TcxPoint> 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) { 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<_TcxPoint> 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<_TcxPoint> 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<_TcxPoint> points) { final times = points.map((p) => p.time).nonNulls.toList(); if (times.isEmpty) return null; times.sort(); return times.first; } static double? _minElevation(List<_TcxPoint> 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<_TcxPoint> 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(_TcxPoint a, _TcxPoint 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); } static String? _normalizeSport(String? raw) { final value = raw?.trim().toLowerCase(); if (value == null || value.isEmpty) return null; switch (value) { case 'running': return 'Corsa'; case 'biking': case 'cycling': return 'Bici'; case 'walking': return 'Camminata'; case 'hiking': return 'Trekking'; case 'swimming': return 'Nuoto'; default: return raw?.trim(); } } } class _TcxPoint { final LatLng latLng; final double? elevationMeters; final DateTime? time; final double? distanceMeters; const _TcxPoint({ required this.latLng, this.elevationMeters, this.time, this.distanceMeters, }); } extension _FirstOrNull on Iterable { E? get firstOrNull { final it = iterator; if (!it.moveNext()) return null; return it.current; } E? firstWhereOrNull(bool Function(E value) test) { for (final value in this) { if (test(value)) return value; } return null; } }