activity_files 0.7.2
activity_files: ^0.7.2 copied to clipboard
A pure Dart toolkit for parsing, editing, validating, and converting workout activity files in GPX, TCX, FIT, GeoJSON, and CSV formats.
import 'dart:io';
import 'dart:typed_data';
import 'package:activity_files/activity_files.dart';
/// Override with `dart run --define=supportsIsolates=false example/main.dart`
/// when running on platforms (e.g. Flutter web) that cannot spawn isolates.
const supportsIsolates = bool.fromEnvironment(
'supportsIsolates',
defaultValue: true,
);
Future<void> main() async {
final sampleFile = File('example/assets/artificial.gpx');
final fallbackFile = File('example/assets/sample.gpx');
final resolvedFile = sampleFile.existsSync() ? sampleFile : fallbackFile;
final sampleBytes = await resolvedFile.readAsBytes();
await _demoLoadAndConvert(sampleBytes);
await _demoFilePathHandling(resolvedFile);
await _buildAndExportSyntheticActivity();
await _exportFromRawStreams();
await _demoBatchDiagnosticsAndEditing();
}
Future<void> _demoBatchDiagnosticsAndEditing() async {
print('=== Batch import, diagnostics & point editing ===');
// --- Batch import ---
final files = [
File('example/assets/sample.gpx'),
File('example/assets/sample.fit'),
File('example/assets/sample.tcx'),
];
final batch = await ActivityFiles.loadBatch(
files,
useIsolate: supportsIsolates,
onProgress: (done, total) => print('Batch: $done/$total'),
);
print('Batch: ${batch.successCount} ok, ${batch.failureCount} failed');
// --- Structured validation diagnostics with suggestedFix ---
if (batch.successes.isNotEmpty) {
final activity = batch.successes.first.activity;
final validation = validateRawActivity(activity);
for (final d in validation.diagnostics) {
final fix = d.suggestedFix != null ? ' → ${d.suggestedFix}' : '';
print('[${d.severity.name}] ${d.code}$fix');
}
final deviceDiags = activity.device != null
? validateDeviceMetadata(activity.device!)
: <ValidationDiagnostic>[];
final channelDiags = validateChannels(activity.channels);
print(
'Device diags: ${deviceDiags.length}, '
'channel diags: ${channelDiags.length}',
);
}
// --- repairDiagnostics after trimInvalid ---
final base = DateTime.utc(2024, 3, 1, 10);
final activityWithSentinels = RawActivity(
points: [
GeoPoint(latitude: 51.5, longitude: -0.1, time: base),
GeoPoint(
latitude: 0.0,
longitude: 0.0,
time: base.add(const Duration(seconds: 5)),
),
],
);
final editor = RawEditor(activityWithSentinels)..trimInvalid();
print(
'repairDiagnostics: ${editor.repairDiagnostics.map((d) => d.code).join(', ')}',
);
print('Points after trim: ${editor.activity.points.length}');
// --- Point-level editing ---
final editBase = DateTime.utc(2024, 6, 1, 9);
final editable = RawActivity(
points: [
GeoPoint(latitude: 40.0, longitude: -105.0, time: editBase),
GeoPoint(
latitude: 40.001,
longitude: -105.001,
time: editBase.add(const Duration(seconds: 30)),
),
GeoPoint(
latitude: 40.002,
longitude: -105.002,
time: editBase.add(const Duration(seconds: 60)),
),
],
channels: {
Channel.heartRate: [
Sample(time: editBase, value: 140),
Sample(time: editBase.add(const Duration(seconds: 30)), value: 145),
Sample(time: editBase.add(const Duration(seconds: 60)), value: 150),
],
},
);
final pointEditor = RawEditor(editable)
..insertPoint(
GeoPoint(
latitude: 40.0005,
longitude: -105.0005,
time: editBase.add(const Duration(seconds: 15)),
),
)
..deleteRange(
editBase.add(const Duration(seconds: 25)),
editBase.add(const Duration(seconds: 35)),
)
..insertPause(
editBase.add(const Duration(seconds: 50)),
const Duration(seconds: 10),
);
print('Points after point edits: ${pointEditor.activity.points.length}');
// --- channelSamplesFrom for re-export round-trip ---
if (batch.successes.isNotEmpty) {
final channelMap = ActivityFiles.channelSamplesFrom(
batch.successes.first.activity,
);
print('channelSamplesFrom: ${channelMap.length} channel(s)');
}
// --- WorkoutSet.categoryLabel ---
print('Category 28: ${WorkoutSet.categoryLabel(28)}'); // Squat
print('Category 8: ${WorkoutSet.categoryLabel(8)}'); // Deadlift
print('Category 99: ${WorkoutSet.categoryLabel(99)}'); // null (unknown)
// --- SwimStroke enum ---
print('Strokes: ${SwimStroke.values.map((s) => s.name).join(', ')}');
// --- ActivitySummary swim fields ---
const swimSummary = ActivitySummary(
poolLengthMeters: 50.0,
numActiveLengths: 40,
swimStroke: SwimStroke.freestyle,
subSport: 45,
totalCycles: 600,
);
print(
'Swim: ${swimSummary.poolLengthMeters}m pool, '
'${swimSummary.numActiveLengths} lengths, '
'stroke=${swimSummary.swimStroke?.name}',
);
}
Future<void> _demoLoadAndConvert(Uint8List sampleBytes) async {
print('=== Load & convert sample.gpx ===');
final loaded = await ActivityFiles.load(
sampleBytes,
format: ActivityFileFormat.gpx,
useIsolate: supportsIsolates,
);
if (loaded.hasErrors) {
print('Load failed:\n${loaded.diagnosticsSummary(includeNode: true)}');
return;
}
print(
'Loaded ${loaded.format.name}: '
'${loaded.activity.points.length} points, '
'${loaded.activity.channels.length} channel(s)',
);
if (loaded.hasWarnings) {
print('Parser warnings:\n${loaded.diagnosticsSummary(includeNode: true)}');
}
final tcxConversion = await ActivityFiles.convert(
source: sampleBytes,
to: ActivityFileFormat.tcx,
runValidation: true,
useIsolate: supportsIsolates,
);
if (tcxConversion.hasErrors) {
print('TCX conversion failed:\n${tcxConversion.diagnosticsSummary()}');
return;
}
print(
'TCX validation errors: ${tcxConversion.validation?.errors.length ?? 0}, '
'diagnostics: ${tcxConversion.diagnostics.length}',
);
final fitExport = await ActivityFiles.convertAndExport(
source: sampleBytes,
from: ActivityFileFormat.gpx,
to: ActivityFileFormat.fit,
runValidation: true,
useIsolate: supportsIsolates,
exportInIsolate: supportsIsolates,
);
if (fitExport.hasErrors) {
print('FIT export failed:\n${fitExport.diagnosticsSummary()}');
return;
}
print('FIT payload bytes: ${fitExport.asBytes().length}');
final streamed = await ActivityFiles.convertAndExportStream(
source: Stream<List<int>>.fromIterable([
for (final chunk in sampleBytes.chunks(64)) chunk,
]),
from: ActivityFileFormat.gpx,
to: ActivityFileFormat.tcx,
runValidation: true,
parseInIsolate: supportsIsolates,
exportInIsolate: supportsIsolates,
);
if (streamed.hasErrors) {
print('Streamed export failed:\n${streamed.diagnosticsSummary()}');
return;
}
print(
'Streamed TCX chars: ${streamed.asString().length}; '
'warnings: ${streamed.warningCount}',
);
final asyncExport = await ActivityFiles.exportAsync(
activity: streamed.activity,
to: ActivityFileFormat.fit,
runValidation: true,
useIsolate: supportsIsolates,
);
if (asyncExport.hasErrors) {
print('Async export failed:\n${asyncExport.diagnosticsSummary()}');
return;
}
print(
'Async export validation errors: '
'${asyncExport.validation?.errors.length ?? 0}',
);
final roundTrip = await ActivityFiles.load(
fitExport.asBytes(),
format: ActivityFileFormat.fit,
useIsolate: supportsIsolates,
);
if (roundTrip.hasErrors) {
print('Round-trip FIT parse failed:\n${roundTrip.diagnosticsSummary()}');
return;
}
print('Round-trip points: ${roundTrip.activity.points.length}');
final summary = roundTrip.activity.summary;
if (summary != null) {
print(
'Summary: ${summary.totalDistanceMeters?.toStringAsFixed(1) ?? 'n/a'}m, '
'elapsed=${summary.elapsedTime?.inSeconds ?? 0}s, '
'avgHR=${summary.avgHeartRate?.toStringAsFixed(0) ?? 'n/a'}',
);
}
}
Future<void> _demoFilePathHandling(File sampleFile) async {
print('=== File path handling (0.4.0+ migration) ===');
// Option 1: Pass File object directly (recommended)
final viaFileObject = await ActivityFiles.load(
sampleFile,
useIsolate: supportsIsolates,
);
print('Via File object: ${viaFileObject.activity.points.length} points');
// Option 2: Use allowFilePaths flag with string path
final viaStringPath = await ActivityFiles.load(
sampleFile.path,
allowFilePaths: true,
useIsolate: supportsIsolates,
);
print('Via string path: ${viaStringPath.activity.points.length} points');
// Note: Without allowFilePaths, string is treated as inline content:
// await ActivityFiles.load('/path/to/file.gpx'); // Would fail!
}
Future<void> _buildAndExportSyntheticActivity() async {
print('=== Build + export synthetic activity ===');
final baseTime = DateTime.utc(2024, 1, 1, 12);
final builder = ActivityFiles.builder()
..sport = Sport.running
..creator = 'Example Watch'
..setDeviceMetadata(
const ActivityDeviceMetadata(
manufacturer: 'Example Labs',
product: '42',
serialNumber: 'ABC123',
softwareVersion: '1.0.0',
),
)
..addPoint(
latitude: 40.0,
longitude: -105.0,
elevation: 1601,
time: baseTime,
)
..addPoint(
latitude: 40.0005,
longitude: -105.0005,
elevation: 1604,
time: baseTime.add(const Duration(seconds: 5)),
)
..addSample(channel: Channel.heartRate, time: baseTime, value: 140)
..addSample(
channel: Channel.heartRate,
time: baseTime.add(const Duration(seconds: 5)),
value: 143,
)
..addLap(
startTime: baseTime,
endTime: baseTime.add(const Duration(seconds: 5)),
distanceMeters: 70,
);
final normalized = ActivityFiles.edit(
builder.build(),
).sortAndDedup().trimInvalid().recomputeDistanceAndSpeed().activity;
final prepared = ActivityFiles.smoothHeartRate(normalized, window: 3);
final gpxExport = ActivityFiles.export(
activity: prepared,
to: ActivityFileFormat.gpx,
runValidation: true,
);
print('Synthetic GPX points: ${gpxExport.activity.points.length}');
if (gpxExport.hasDiagnostics) {
print('Synthetic diagnostics:\n${gpxExport.diagnosticsSummary()}');
}
}
Future<void> _exportFromRawStreams() async {
print('=== Convert from raw timestamp/value streams ===');
final base = DateTime.utc(2024, 1, 2, 7);
final ts0 = base.millisecondsSinceEpoch;
final device = ActivityDeviceMetadata(
manufacturer: 'ActivityFiles',
model: 'CLI Device',
);
final export = await ActivityFiles.convertAndExport(
location: [
(timestamp: ts0, latitude: 40.0, longitude: -105.0, elevation: 1600),
(
timestamp: ts0 + 1000,
latitude: 40.0003,
longitude: -105.0003,
elevation: 1602,
),
],
channels: {
Channel.heartRate: [
(timestamp: ts0, value: 138),
(timestamp: ts0 + 1000, value: 141),
],
},
label: 'Stream Demo',
creator: 'example-main.dart',
sportSource: 'running',
device: device,
gpxMetadataDescription: 'Stream helper export',
includeCreatorInGpxMetadataDescription: false,
metadataExtensions: [ActivityFiles.gpxActivityLabelNode('Stream Demo')],
trackExtensions: [ActivityFiles.gpxDeviceSummaryNode(device)],
to: ActivityFileFormat.gpx,
normalize: true,
runValidation: true,
);
if (export.hasErrors) {
print('Stream helper export failed:\n${export.diagnosticsSummary()}');
return;
}
print('Stream helper points: ${export.activity.points.length}');
}
extension on List<int> {
Iterable<List<int>> chunks(int size) sync* {
for (var i = 0; i < length; i += size) {
final end = (i + size) > length ? length : i + size;
yield sublist(i, end);
}
}
}