// 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:flutter/foundation.dart'; 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; Duration? sessionDuration; 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; } // Detect Zepp device if (fields['manufacturer'] == 25 || fields['product'] == 25 || fields['serial_number']?.toString().contains('zepp') == true) { isZepp = true; } if (messageName == 'record') { final latRaw = _readNumber(fields, const [ 'position_lat', 'positionlat', 'lat', 'latitude', ]); final lonRaw = _readNumber(fields, const [ 'position_long', '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', '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', 'distance', ]); final elapsedSeconds = _readNumber(fields, const [ 'total_elapsed_time', 'total_timer_time', ]); if (elapsedSeconds != null) { sessionDuration = Duration(seconds: elapsedSeconds.round()); } 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) .toList(); // Build track List track = points.map((p) => p.latLng).toList(); // Apply smoothing ONLY for Zepp if (isZepp) { track = GpsSmoothing.douglasPeucker(track, 5.0); } final activity = ActivityTrack( name: fileName, format: 'FIT', tracks: {track}, pointCount: track.length, distanceMeters: sessionDistanceMeters ?? _computeDistance(track), elevationGainMeters: _computeElevation(points), duration: sessionDuration ?? _computeDuration(points), startTime: points.map((p) => p.time).nonNulls.firstOrNull, sport: sport, ); return activity; } static double? _readNumber(Map fields, List names) { for (final name in names) { final value = fields[name]; if (value == null) continue; if (value is num) return value.toDouble(); if (value is String) return double.tryParse(value); } return null; } static DateTime? _readDateTime(Map fields, List names) { for (final name in names) { final value = fields[name]; if (value == null) continue; if (value is DateTime) return value; if (value is int && value > 0) { return DateTime.fromMillisecondsSinceEpoch( (value + 631065600) * 1000, isUtc: true, ); } if (value is String) { final parsed = DateTime.tryParse(value); if (parsed != null) return parsed.toUtc(); } } return null; } static double _toDegrees(num raw) { final v = raw.toDouble(); if (v.abs() <= 180) return v; return v * 180.0 / 2147483648.0; } static double? _computeDistance(List track) { if (track.length < 2) return null; double dist = 0; for (int i = 1; i < track.length; i++) { final seg = _distance.as(LengthUnit.Meter, track[i - 1], track[i]); if (seg < 0 || seg > 50) continue; // anti‑rumore Zepp dist += seg; } return dist; } static double? _computeElevation(List<_FitPoint> points) { double gain = 0; double? prev; for (final p in points) { final e = p.elevationMeters; if (e == null) continue; if (prev != null) { final delta = e - prev; if (delta > 2) gain += delta; // soglia Zepp } prev = e; } return gain; } static Duration? _computeDuration(List<_FitPoint> points) { final times = points.map((p) => p.time).nonNulls.toList(); if (times.length < 2) return null; times.sort(); return times.last.difference(times.first); } 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, this.time, required this.rawFields, }); } extension _FirstOrNull on Iterable { E? get firstOrNull { final it = iterator; if (!it.moveNext()) return null; return it.current; } }