// lib/utils/fit_activity_parser.dart import 'dart:typed_data'; import 'package:aves/model/activity/activity_track.dart'; import 'package:fit_sdk/fit_sdk.dart' as fit; import 'package:latlong2/latlong.dart'; import 'gps_smoothing.dart'; class FitActivityParser { static final Distance _distance = Distance(); static ActivityTrack parse( Uint8List bytes, { required String fileName, }) { final records = <_FitPoint>[]; double? sessionDistanceMeters; double? sessionElevationGainMeters; Duration? sessionDuration; DateTime? sessionStartTime; String? sport; bool isZepp = false; final decoder = fit.Decode(); decoder.onMesg = (dynamic message) { final messageName = '${message.name}'.toLowerCase(); final fields = {}; for (final dynamic field in message.fields) { fields['${field.name}'.toLowerCase()] = field.value; } if (_looksLikeZepp(fields)) { isZepp = true; } if (messageName == 'record') { final latRaw = _readNumber(fields, const [ 'position_lat', 'positionLat', 'positionlat', 'lat', 'latitude', ]); final lonRaw = _readNumber(fields, const [ 'position_long', 'positionLong', 'positionlong', 'lon', 'longitude', ]); if (latRaw == null || lonRaw == null) return; final lat = _toDegrees(latRaw); final lon = _toDegrees(lonRaw); if (!lat.isFinite || !lon.isFinite) return; if (lat.abs() > 90 || lon.abs() > 180) return; final elevation = _readNumber(fields, const [ 'enhanced_altitude', 'enhancedAltitude', 'enhancedaltitude', 'altitude', ]); final time = _readDateTime(fields, const [ 'timestamp', 'time', ]); records.add( _FitPoint( latLng: LatLng(lat, lon), elevationMeters: elevation, time: time, rawFields: fields, ), ); } if (messageName == 'session') { sessionDistanceMeters ??= _readNumber(fields, const [ 'total_distance', 'totalDistance', 'totaldistance', 'distance', ]); // Priorita assoluta per il D+ aggregato presente nel FIT. // Questo corrisponde al campo Total Ascent / total_ascent / totalAscent. sessionElevationGainMeters ??= _readNumber(fields, const [ 'total_ascent', 'totalAscent', 'totalascent', 'ascent', 'total_elevation_gain', 'totalElevationGain', 'totalElevationGainMeters', ]); final elapsedSeconds = _readNumber(fields, const [ 'total_elapsed_time', 'totalElapsedTime', 'total_timer_time', 'totalTimerTime', ]); if (elapsedSeconds != null && elapsedSeconds > 0) { sessionDuration = Duration(seconds: elapsedSeconds.round()); } sessionStartTime ??= _readDateTime(fields, const [ 'start_time', 'startTime', 'timestamp', 'time', ]); final sessionSport = fields['sport']; if (sessionSport != null) { sport = _normalizeSport('$sessionSport'); } } if (messageName == 'sport') { final sportValue = fields['sport']; if (sportValue != null) { sport = _normalizeSport('$sportValue'); } } }; decoder.read(bytes); final points = records .where((p) => p.latLng.latitude.isFinite && p.latLng.longitude.isFinite && p.latLng.latitude.abs() <= 90 && p.latLng.longitude.abs() <= 180) .toList(); points.sort(_compareByTimeKeepingNullsLast); final originalTrack = points.map((p) => p.latLng).toList(); // Semplifico solo la visualizzazione. Distanza e quota usano i punti originali. final displayTrack = isZepp ? GpsSmoothing.douglasPeucker(originalTrack, 5.0) : originalTrack; final computedDistanceMeters = _computeDistanceFromPoints(points, sport: sport); final computedElevationGainMeters = _computeElevationLikeCommercialApps(points); final computedDuration = _computeDuration(points); final computedStartTime = _computeStartTime(points); // Regola distanza richiesta: // 1) se nel FIT esiste totalDistance/total_distance plausibile, uso quello; // 2) altrimenti calcolo la distanza dai punti GPS con filtri anti-spike. final finalDistanceMeters = _chooseDistanceMeters( sessionDistanceMeters: sessionDistanceMeters, computedDistanceMeters: computedDistanceMeters, ); // Regola D+ richiesta: // 1) se nel FIT esiste totalAscent/total_ascent plausibile, uso quello; // 2) altrimenti calcolo il D+ con smoothing + soglie, stile app commerciali. final finalElevationGainMeters = _chooseElevationGainMeters( sessionElevationGainMeters: sessionElevationGainMeters, computedElevationGainMeters: computedElevationGainMeters, ); return ActivityTrack( name: fileName, format: 'FIT', tracks: {displayTrack}, pointCount: points.length, distanceMeters: finalDistanceMeters, elevationGainMeters: finalElevationGainMeters, duration: sessionDuration ?? computedDuration, startTime: sessionStartTime ?? computedStartTime, sport: sport, diagnostics: [ 'isZepp=$isZepp', 'sessionDistanceMeters=$sessionDistanceMeters', 'computedDistanceMeters=$computedDistanceMeters', 'finalDistanceMeters=$finalDistanceMeters', 'sessionElevationGainMeters=$sessionElevationGainMeters', 'computedElevationGainMeters=$computedElevationGainMeters', 'finalElevationGainMeters=$finalElevationGainMeters', 'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}', 'elevationMin=${_minElevation(points)}', 'elevationMax=${_maxElevation(points)}', 'originalPoints=${points.length}', 'displayPoints=${displayTrack.length}', ].join('\n'), ); } static bool _looksLikeZepp(Map fields) { final manufacturer = fields['manufacturer']?.toString().toLowerCase() ?? ''; final product = fields['product']?.toString().toLowerCase() ?? ''; final deviceName = fields['device_name']?.toString().toLowerCase() ?? ''; final descriptor = fields['descriptor']?.toString().toLowerCase() ?? ''; final serial = fields['serial_number']?.toString().toLowerCase() ?? ''; final all = '$manufacturer $product $deviceName $descriptor $serial'; return all.contains('zepp') || all.contains('amazfit') || all.contains('huami'); } static double? _readNumber(Map fields, List names) { for (final name in names) { final direct = fields[name]; if (direct != null) { final parsed = _parseNumber(direct); if (parsed != null) return parsed; } final normalizedName = _normalizeFieldName(name); for (final entry in fields.entries) { if (_normalizeFieldName(entry.key) == normalizedName) { final parsed = _parseNumber(entry.value); if (parsed != null) return parsed; } } } return null; } static String _normalizeFieldName(String value) { return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); } static double? _parseNumber(dynamic value) { if (value == null) return null; if (value is num) return value.toDouble(); return double.tryParse(value.toString().replaceAll(',', '.')); } static DateTime? _readDateTime( Map fields, List names, ) { for (final name in names) { final direct = fields[name]; if (direct != null) { final parsed = _parseDateTime(direct); if (parsed != null) return parsed; } final normalizedName = _normalizeFieldName(name); for (final entry in fields.entries) { if (_normalizeFieldName(entry.key) == normalizedName) { final parsed = _parseDateTime(entry.value); if (parsed != null) return parsed; } } } return null; } static DateTime? _parseDateTime(dynamic value) { if (value == null) return null; if (value is DateTime) return value.toUtc(); if (value is int && value > 0) { // FIT timestamp: secondi dal 1989-12-31 00:00:00 UTC. return DateTime.fromMillisecondsSinceEpoch( (value + 631065600) * 1000, isUtc: true, ); } final parsed = DateTime.tryParse(value.toString()); return parsed?.toUtc(); } static double _toDegrees(num raw) { final v = raw.toDouble(); if (v.abs() <= 180) return v; return v * 180.0 / 2147483648.0; } static double? _computeDistanceFromPoints( List<_FitPoint> points, { String? sport, }) { if (points.length < 2) return null; final maxSpeedMps = _maxPlausibleSpeedMps(sport); 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 > 300) continue; dist += seg; continue; } if (seg < 0.3) continue; final speedMps = seg / dt; if (speedMps > maxSpeedMps) continue; dist += seg; } return dist > 0 ? dist : null; } static double _maxPlausibleSpeedMps(String? sport) { final value = sport?.toLowerCase() ?? ''; if (value.contains('bici') || value.contains('bike') || value.contains('cycling') || value.contains('mountain')) { return 30; } if (value.contains('corsa') || value.contains('running')) { return 12; } if (value.contains('cammin') || value.contains('walking') || value.contains('trekking') || value.contains('hiking')) { return 8; } return 25; } static double? _chooseDistanceMeters({ required double? sessionDistanceMeters, required double? computedDistanceMeters, }) { final session = _normalizeDistanceMeters(sessionDistanceMeters); if (session != null) return session; return _normalizeDistanceMeters(computedDistanceMeters); } static double? _normalizeDistanceMeters(double? value) { if (value == null || !value.isFinite || value <= 0) return null; // Limite prudente: evita dati palesemente corrotti. // 500 km copre ultra, bici lunghe e la maggior parte delle attivita outdoor. const maxPlausibleMeters = 500000.0; // Alcuni decoder/librerie possono restituire valori FIT non scalati. // total_distance FIT ha scala 100: 1234567 puo significare 12345.67 m. if (value > maxPlausibleMeters && value / 100 <= maxPlausibleMeters) { return value / 100; } if (value > maxPlausibleMeters) return null; return value; } static double? _chooseElevationGainMeters({ required double? sessionElevationGainMeters, required double? computedElevationGainMeters, }) { final session = _normalizeElevationGain(sessionElevationGainMeters); if (session != null) return session; return _normalizeElevationGain(computedElevationGainMeters); } static double? _normalizeElevationGain(double? value) { if (value == null || !value.isFinite || value <= 0) return null; // Valore molto prudente: evita solo dati evidentemente corrotti/scalati male. if (value > 20000) return null; return value; } /// Fallback quando totalAscent non esiste. /// /// Logica ispirata alle app commerciali: /// - rimozione valori altimetrici impossibili; /// - smoothing mediano leggero; /// - rimozione spike verticali; /// - somma delle salite valle-picco solo oltre una soglia minima. /// /// Non calcola mai quota finale - quota iniziale, quindi sui giri circolari /// il D+ non si annulla. static double? _computeElevationLikeCommercialApps(List<_FitPoint> 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; } // Quando scendo abbastanza dal picco, considero terminata una salita. 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<_FitPoint> 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<_FitPoint> points) { final times = points.map((p) => p.time).nonNulls.toList(); if (times.isEmpty) return null; times.sort(); return times.first; } static double? _minElevation(List<_FitPoint> 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<_FitPoint> 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(_FitPoint a, _FitPoint 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 double? _normalizeSuspiciousDistance(double? value) { return _normalizeDistanceMeters(value); } static String _normalizeSport(String raw) { final value = raw.trim().toLowerCase(); final numeric = int.tryParse(value); if (numeric != null) { switch (numeric) { case 1: return 'Corsa'; case 2: return 'Bici'; case 5: return 'Nuoto'; case 6: return 'Camminata'; case 9: return 'Trekking'; case 20: return 'Mountain Bike'; default: return 'Sport $numeric'; } } switch (value) { case 'running': return 'Corsa'; case 'cycling': return 'Bici'; case 'walking': return 'Camminata'; case 'hiking': return 'Trekking'; case 'swimming': return 'Nuoto'; default: return raw.trim(); } } } class _FitPoint { final LatLng latLng; final double? elevationMeters; final DateTime? time; final Map rawFields; const _FitPoint({ required this.latLng, this.elevationMeters, required this.time, required this.rawFields, }); }