267 lines
6.6 KiB
Text
267 lines
6.6 KiB
Text
// 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 = <String, dynamic>{};
|
||
|
||
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<LatLng> 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<String, dynamic> fields, List<String> 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<String, dynamic> fields, List<String> 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<LatLng> 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<String, dynamic> rawFields;
|
||
|
||
const _FitPoint({
|
||
required this.latLng,
|
||
this.elevationMeters,
|
||
this.time,
|
||
required this.rawFields,
|
||
});
|
||
}
|
||
|
||
extension _FirstOrNull<E> on Iterable<E> {
|
||
E? get firstOrNull {
|
||
final it = iterator;
|
||
if (!it.moveNext()) return null;
|
||
return it.current;
|
||
}
|
||
}
|