activity_files
Licensed under the BSD 3-Clause License. See LICENSE for details.
A pure Dart toolkit for reading, editing, validating, and writing workout
activity files. activity_files provides format-agnostic models, robust GPX and
TCX parsers/encoders, transformation utilities, and a CLI for quick conversions
or validation.
Highlights
- Unified
RawActivitymodel with geographic samples, sensor channels, laps, and sport metadata. - High-level
ActivityFilesfacade for loading, converting, and building activities with minimal setup. - Stream-first builders (
builderFromStreams) and export helpers accept raw timestamp/value tuples so backend streams can export without manualGeoPoint/Samplewiring. - One-call export pipeline (
ActivityFiles.export/convertAndExport) that normalizes, validates, and returns encoded payloads plus aggregated diagnostics, with optional isolate offloading viaexportInIsolate. - Pluggable sport inference hooks plus GPX device/label helpers remove custom mapping glue for popular wearables.
- Declarative
ActivityExportRequestfor orchestrating parse → normalize → export flows from a single builder-style object. - DiagnosticsFormatter utilities and processing stats for consistent diagnostic summaries across logs, CLIs, and UIs.
- Namespace-tolerant GPX/TCX parsers that surface non-fatal schema issues as warnings instead of throwing.
- Channel-aware encoders with configurable matching tolerances and numeric
precision via
EncoderOptions. - Immutable editing pipeline (
RawEditor,RawTransforms) for cropping, resampling, smoothing, and derived metrics. - Reliability improvements to lap generation, downsampling, and deduplication ensure transforms preserve the latest sensor samples and emit accurate splits.
- Structural validation helpers producing concise error/warning reports.
- Builder-level device metadata and namespace-aware GPX extensions for richer downstream encoders.
- Optional CLI (
bin/activity_files.dart) for converting files or running validations from the terminal.
Getting started
Add the package to pubspec.yaml:
dependencies:
activity_files: ^0.3.0
Then install dependencies:
dart pub get
See example/main.dart for a minimal round-trip through the encoders.
Quick start
Flutter app (mobile & web)
Bundle your sample data alongside the app so it is available on every platform:
flutter:
assets:
- assets/ride.gpx
Load the asset via rootBundle, wire the isolate toggle for web, and expose
helpers your widgets can call:
import 'dart:typed_data';
import 'package:activity_files/activity_files.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class ActivityRepository {
Future<ActivityLoadResult> loadRideFromAssets() async {
final asset = await rootBundle.load('assets/ride.gpx');
final bytes = asset.buffer.asUint8List();
return ActivityFiles.load(
bytes,
format: ActivityFileFormat.gpx,
useIsolate: !kIsWeb,
);
}
Future<ActivityExportResult> convertToFit(Uint8List gpxBytes) {
return ActivityFiles.convertAndExport(
source: gpxBytes,
from: ActivityFileFormat.gpx,
to: ActivityFileFormat.fit,
runValidation: true,
useIsolate: !kIsWeb,
exportInIsolate: !kIsWeb,
);
}
Future<ActivityExportResult> buildSyntheticRun() async {
final builder = ActivityFiles.builder()
..sport = Sport.running
..setDeviceMetadata(
const ActivityDeviceMetadata(
manufacturer: 'Example Watch',
fitManufacturerId: 201,
fitProductId: 42,
),
)
..addPoint(
latitude: 40.0,
longitude: -105.0,
time: DateTime.utc(2024, 5, 1, 7, 30),
)
..addSample(
channel: Channel.heartRate,
time: DateTime.utc(2024, 5, 1, 7, 30),
value: 142,
);
final activity = builder.build();
return ActivityFiles.export(
activity: activity,
to: ActivityFileFormat.gpx,
);
}
}
Drop the repository into a widget so newcomers can see the end-to-end flow:
class RidePreview extends StatelessWidget {
const RidePreview({super.key, required this.repository});
final ActivityRepository repository;
@override
Widget build(BuildContext context) {
return FutureBuilder<ActivityLoadResult>(
future: repository.loadRideFromAssets(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
if (snapshot.hasError || !snapshot.hasData) {
return Text('Failed to load: ${snapshot.error}');
}
final ride = snapshot.data!;
return Text(
'Format: ${ride.format.name}, points: ${ride.activity.points.length}',
);
},
);
}
}
Web note: Flutter web does not support isolates. Always pass
useIsolate: false(andexportInIsolate: false) when targeting the web. The snippets above gate those flags with!kIsWebfor convenience.
Raw streams
Backends that expose timestamp/value arrays can skip manual GeoPoint and
Sample assembly by using the stream-aware helpers:
final device = ActivityDeviceMetadata(
manufacturer: 'Withings',
model: 'ScanWatch',
);
final export = await ActivityFiles.convertAndExport(
location: locationStream,
channels: {
Channel.heartRate: heartRateStream,
Channel.temperature: temperatureStream,
},
laps: lapStream,
label: 'Morning Run',
creator: 'withings-exporter',
sportSource: withingsCategory,
device: device,
gpxMetadataDescription: 'Withings export',
includeCreatorInGpxMetadataDescription: false,
metadataExtensions: [
ActivityFiles.gpxActivityLabelNode('Morning Run'),
],
trackExtensions: [
ActivityFiles.gpxDeviceSummaryNode(
device,
extras: {'battery': 95},
),
],
to: ActivityFileFormat.gpx,
normalize: true,
runValidation: true,
);
Each stream uses records (({timestamp, latitude, longitude, elevation}) and
({timestamp, value})), making it trivial to forward arrays from REST or gRPC
payloads. When wearable categories differ from the built-in Sport enum, call
ActivityFiles.registerSportMapper once during startup to plug in your own
mapping strategy.
Dart VM / CLI
If you are running on the Dart VM (CLI tools, servers, tests), you can work with
paths and dart:io just as before:
import 'dart:io';
import 'package:activity_files/activity_files.dart';
Future<void> bootstrap() async {
final ride = await ActivityFiles.load(
File('assets/ride.gpx'),
useIsolate: true,
);
print('Detected format: ${ride.format}, points: ${ride.activity.points.length}');
final fit = await ActivityFiles.convertAndExport(
source: File('assets/ride.gpx'),
to: ActivityFileFormat.fit,
runValidation: true,
exportInIsolate: true,
);
await File('ride.fit').writeAsBytes(fit.asBytes());
}
Async export & streaming
Web note: Flutter web does not support isolates. Pass
useIsolate: false(and disableexportInIsolate) when running these helpers in a web build.
Future<void> exportOffMainThread(
RawActivity activity, {
bool supportsIsolates = true,
}) async {
final result = await ActivityFiles.exportAsync(
activity: activity,
to: ActivityFileFormat.fit,
runValidation: true,
useIsolate: supportsIsolates,
);
print('Normalization Δ: ${result.processingStats.normalization?.pointsDelta}');
print('Validation took: ${result.processingStats.validationDuration}');
}
Future<void> convertStreamedGpx(Stream<List<int>> stream) async {
final request = ActivityExportRequest.fromStream(
stream: stream,
from: ActivityFileFormat.gpx,
to: ActivityFileFormat.tcx,
runValidation: true,
);
final result = await ActivityFiles.runPipeline(request);
await File('streamed.tcx').writeAsString(result.asString());
}
Future<void> pipelineFromPath(String path) async {
final result = await ActivityFiles.runPipeline(
ActivityExportRequest.fromSource(
source: path,
from: null, // auto-detects GPX/TCX/FIT
to: ActivityFileFormat.fit,
runValidation: true,
exportInIsolate: true,
),
);
await File('converted.fit').writeAsBytes(result.asBytes());
}
Streaming caveat: the current
parseStreamimplementation buffers the entire stream in memory before parsing so it can rewind when needed. This keeps the API consistent across platforms today but means very large uploads still need enough RAM for the full payload. If you need true incremental parsing you can feed the parser with your own chunked loader that enforces back-pressure and chunk sizes.
RawActivity model
final activity = RawActivity(
points: [
GeoPoint(latitude: 40.0, longitude: -105.0, elevation: 1601, time: DateTime.utc(2024, 3, 1, 10)),
GeoPoint(latitude: 40.0005, longitude: -105.0005, elevation: 1602, time: DateTime.utc(2024, 3, 1, 10, 0, 10)),
],
channels: {
Channel.heartRate: [
Sample(time: DateTime.utc(2024, 3, 1, 10), value: 140),
Sample(time: DateTime.utc(2024, 3, 1, 10, 0, 10), value: 142),
],
},
laps: [
Lap(
startTime: DateTime.utc(2024, 3, 1, 10),
endTime: DateTime.utc(2024, 3, 1, 10, 0, 10),
distanceMeters: 70,
),
],
sport: Sport.running,
creator: 'Example Watch',
);
Parsing and encoding
// Parse GPX to RawActivity (plus non-fatal diagnostics).
final result = ActivityParser.parse(gpxString, ActivityFileFormat.gpx);
for (final warning in result.warningDiagnostics) {
final node = warning.node?.format();
final context = node != null ? ' @ $node' : '';
print('Warning ${warning.code}$context: ${warning.message}');
}
final formatter = DiagnosticsFormatter(result.diagnostics);
print('Warnings: ${formatter.warningCount}, errors: ${formatter.errorCount}');
print(formatter.summary(includeNode: true));
final activity = result.activity;
// Encode back to TCX with custom tolerances & precision.
final options = EncoderOptions(
defaultMaxDelta: const Duration(seconds: 2),
precisionLatLon: 6,
precisionEle: 1,
maxDeltaPerChannel: {
Channel.heartRate: const Duration(seconds: 1),
Channel.cadence: const Duration(seconds: 1),
},
);
final tcxString = ActivityEncoder.encode(activity, ActivityFileFormat.tcx, options: options);
final fitBase64 = ActivityEncoder.encode(activity, ActivityFileFormat.fit, options: options);
final fitBytes = base64Decode(fitBase64);
final fitActivity =
ActivityParser.parseBytes(fitBytes, ActivityFileFormat.fit).activity;
> Tip: use `ActivityParser.parseStream` for large files (e.g. `File(...).openRead()`)
> and `ActivityParser.parseBytes` when you already have the payload in memory. Call
> `ActivityParser.parseAsync(..., useIsolate: true)` to offload heavy parses from the UI thread.
Editing pipeline
final editor = ActivityFiles.edit(activity)
.sortAndDedup()
.trimInvalid()
.crop(activity.startTime!, activity.endTime!.subtract(const Duration(minutes: 1)))
.downsampleTime(const Duration(seconds: 5))
.smoothHR(5)
.recomputeDistanceAndSpeed();
final cleaned = editor.activity;
final resampled = RawTransforms.resample(cleaned, step: const Duration(seconds: 2));
final (activity: withDistance, totalDistance: total) = RawTransforms.computeCumulativeDistance(resampled);
print('Distance: ${total.toStringAsFixed(1)} m');
Validation
final validation = validateRawActivity(withDistance);
if (validation.errors.isEmpty) {
print('Activity valid with ${validation.warnings.length} warning(s).');
} else {
print('Validation failed:');
validation.errors.forEach(print);
}
Converter facade
Future<void> convertGpxToTcx() async {
final conversion = await ActivityFiles.convert(
source: gpxString,
to: ActivityFileFormat.tcx,
options: options,
useIsolate: false,
);
for (final diagnostic in conversion.diagnostics) {
print('${diagnostic.severity.name}: ${diagnostic.message}');
}
final tcxString = conversion.asString();
final normalized = conversion.activity;
print(
'Loaded ${conversion.sourceFormat.name} → '
'${conversion.targetFormat.name}, points: ${normalized.points.length}',
);
}
// Note: `ActivityFiles.convert` and `convertAndExport` both accept an
// `exportInIsolate` flag when you want to offload heavy encoding work to a
// background isolate while keeping parsing control via `useIsolate`.
Future<void> convertGpxToFit() async {
final conversion = await ActivityFiles.convert(
source: gpxString,
to: ActivityFileFormat.fit,
options: options,
exportInIsolate: true,
useIsolate: false,
);
final fitBytes = conversion.asBytes();
final roundTrip = await ActivityFiles.load(
fitBytes,
format: ActivityFileFormat.fit,
useIsolate: false,
);
print('FIT diagnostics: ${conversion.diagnostics.length}');
print('Round-trip points: ${roundTrip.activity.points.length}');
}
Future<void> exportWithDiagnostics(RawActivity activity) async {
final export = ActivityFiles.export(
activity: activity,
to: ActivityFileFormat.gpx,
);
if (export.hasDiagnostics) {
print(export.diagnosticsSummary());
}
await File('normalized.gpx').writeAsString(export.asString());
}
Future<void> convertAndExportWithValidation(String path) async {
final export = await ActivityFiles.convertAndExport(
source: path,
to: ActivityFileFormat.fit,
runValidation: true,
exportInIsolate: true,
useIsolate: false,
);
if (export.hasWarnings) {
print('Warnings: ${export.warningCount}');
}
await File('converted.fit').writeAsBytes(export.asBytes());
}
CLI usage
$ dart run bin/activity_files.dart convert --from gpx --to tcx -i ride.gpx -o ride.tcx \
--max-delta-seconds 2 --precision-latlon 7 --hr-max-delta 1
$ dart run bin/activity_files.dart validate --format gpx -i ride.gpx --gap-threshold 180
The CLI reports parser diagnostics, validation warnings, and exits with a non-zero
status when validation errors are detected.
Binary FIT inputs are read directly from .fit files, and FIT outputs are
written as binary files (base64 is only used when you opt into the string APIs).
Contributing
Issues and pull requests are welcome, especially for additional format fixtures. The package is released under the BSD 3-Clause license.