activity_files 0.7.2 copy "activity_files: ^0.7.2" to clipboard
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.

example/main.dart

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);
    }
  }
}
4
likes
160
points
3.22k
downloads

Documentation

API reference

Publisher

verified publishereikedreier.xyz

Weekly Downloads

A pure Dart toolkit for parsing, editing, validating, and converting workout activity files in GPX, TCX, FIT, GeoJSON, and CSV formats.

Repository (GitHub)
View/report issues

Topics

#health #fitness #workout #converter #sports

License

BSD-3-Clause (license)

Dependencies

args, async, collection, csv, xml

More

Packages that depend on activity_files