274 lines
6.4 KiB
Dart
274 lines
6.4 KiB
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';
|
|
|
|
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 = <String, dynamic>{};
|
|
|
|
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 = <List<LatLng>>{};
|
|
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<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();
|
|
}
|
|
|
|
final parsed = double.tryParse('$value');
|
|
if (parsed != null && parsed.isFinite) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 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<E> on Iterable<E> {
|
|
E? get firstOrNull {
|
|
final iterator = this.iterator;
|
|
if (!iterator.moveNext()) return null;
|
|
return iterator.current;
|
|
}
|
|
}
|
|
|