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'; class FitActivityParser { static final Distance _distance = Distance(); static ActivityTrack parse( Uint8List bytes, { required String fileName, }) { final records = <_FitPoint>[]; double? sessionDistanceMeters; Duration? sessionDuration; String? sport; final decoder = fit.Decode(); decoder.onMesg = (dynamic message) { final messageName = '${message.name}'.toLowerCase(); final fields = {}; for (final dynamic field in message.fields) { final name = '${field.name}'.toLowerCase(); fields[name] = field.value; } if (messageName == 'record') { final latRaw = _readNumber(fields, const [ 'position_lat', 'positionlat', 'lat', 'latitude', ]); final lonRaw = _readNumber(fields, const [ 'position_long', 'positionlon', 'lon', 'long', 'longitude', ]); if (latRaw == null || lonRaw == null) return; final lat = _semicirclesToDegrees(latRaw); final lon = _semicirclesToDegrees(lonRaw); if (!lat.isFinite || !lon.isFinite) return; if (lat.abs() > 90 || lon.abs() > 180) return; final elevation = _readNumber(fields, const [ 'altitude', 'enhanced_altitude', ]); final time = _readDateTime(fields, const [ 'timestamp', 'time', ]); records.add( _FitPoint( latLng: LatLng(lat, lon), elevationMeters: elevation, time: time, ), ); } 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( (point) => point.latLng.latitude.isFinite && point.latLng.longitude.isFinite, ) .toList(); final tracks = >{}; if (points.length >= 2) { tracks.add(points.map((point) => point.latLng).toList()); } return ActivityTrack( name: fileName, format: 'FIT', tracks: tracks, pointCount: points.length, distanceMeters: sessionDistanceMeters ?? _computeDistanceMeters(points), elevationGainMeters: _computeElevationGainMeters(points), duration: sessionDuration ?? _computeDuration(points), startTime: points.map((point) => point.time).nonNulls.firstOrNull, sport: sport, ); } 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(); } final parsed = double.tryParse('$value'); if (parsed != null && parsed.isFinite) { return parsed; } } 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) { // FIT timestamp = seconds since 1989-12-31 UTC. return DateTime.fromMillisecondsSinceEpoch( (value + 631065600) * 1000, isUtc: true, ); } final parsed = DateTime.tryParse('$value'); if (parsed != null) return parsed; } return null; } static double _semicirclesToDegrees(num value) { return value.toDouble() * 180.0 / 2147483648.0; } static double? _computeDistanceMeters(List<_FitPoint> points) { if (points.length < 2) return null; var distanceMeters = 0.0; for (var i = 1; i < points.length; i++) { distanceMeters += _distance.as( LengthUnit.Meter, points[i - 1].latLng, points[i].latLng, ); } return distanceMeters > 0 ? distanceMeters : null; } static double? _computeElevationGainMeters(List<_FitPoint> points) { var gain = 0.0; double? previousElevation; for (final point in points) { final elevation = point.elevationMeters; if (elevation == null) continue; final previous = previousElevation; if (previous != null) { final delta = elevation - previous; if (delta > 0) { gain += delta; } } previousElevation = elevation; } return gain > 0 ? gain : null; } static Duration? _computeDuration(List<_FitPoint> points) { final times = points.map((point) => point.time).nonNulls.toList(); if (times.length < 2) return null; times.sort(); final duration = times.last.difference(times.first); return duration.isNegative ? null : duration; } static String _normalizeSport(String raw) { final value = raw.trim().toLowerCase(); switch (value) { case 'running': case 'run': return 'Corsa'; case 'cycling': case 'biking': case 'bike': return 'Bici'; case 'walking': case 'walk': return 'Camminata'; case 'hiking': case 'trekking': return 'Trekking'; case 'swimming': return 'Nuoto'; default: return raw.trim(); } } } class _FitPoint { final LatLng latLng; final double? elevationMeters; final DateTime? time; const _FitPoint({ required this.latLng, this.elevationMeters, this.time, }); } extension _FirstOrNull on Iterable { E? get firstOrNull { final iterator = this.iterator; if (!iterator.moveNext()) return null; return iterator.current; } }