Skip to main content

route_simulator 0.1.2 copy "route_simulator: ^0.1.2" to clipboard
route_simulator: ^0.1.2 copied to clipboard

Pure Dart GPS route simulator: realistic fake location streams with speed profiles, stops, seeded noise, signal loss, a virtual clock, GPX export and emulator playback.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' show NumberFormat;
import 'package:route_simulator/route_simulator.dart';

import 'l10n/app_localizations.dart';

const _brandNavy = Color(0xFF0B2545);
const _brandBlue = Color(0xFF1D4ED8);
const _border = Color(0xFFE2E8F0);
const _muted = Color(0xFF64748B);
const _seed = 42;

void main() {
  runApp(const SimulatorDemoApp());
}

class SimulatorDemoApp extends StatelessWidget {
  const SimulatorDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    final scheme = ColorScheme.fromSeed(
      seedColor: _brandNavy,
      primary: _brandNavy,
      secondary: _brandBlue,
    );
    return MaterialApp(
      onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: AppLocalizations.supportedLocales,
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: scheme,
        scaffoldBackgroundColor: const Color(0xFFF4F6FA),
        appBarTheme: const AppBarTheme(
          backgroundColor: _brandNavy,
          foregroundColor: Colors.white,
        ),
        cardTheme: CardThemeData(
          elevation: 0,
          margin: EdgeInsets.zero,
          color: Colors.white,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
            side: const BorderSide(color: _border),
          ),
        ),
        segmentedButtonTheme: SegmentedButtonThemeData(
          style: SegmentedButton.styleFrom(
            selectedBackgroundColor: _brandNavy,
            selectedForegroundColor: Colors.white,
            side: const BorderSide(color: Color(0xFFCBD5E1)),
            visualDensity: VisualDensity.compact,
          ),
        ),
      ),
      home: const SimulatorScreen(),
    );
  }
}

enum _Preset { city, highway }

/// A loop around Kadıköy, Istanbul, given as points.
final _cityRoute = SimRoute(const [
  GeoPoint(40.99090, 29.02350),
  GeoPoint(40.98960, 29.02540),
  GeoPoint(40.98780, 29.02620),
  GeoPoint(40.98570, 29.02690),
  GeoPoint(40.98400, 29.02810),
  GeoPoint(40.98250, 29.02950),
  GeoPoint(40.98330, 29.03190),
  GeoPoint(40.98520, 29.03250),
  GeoPoint(40.98760, 29.03180),
  GeoPoint(40.98950, 29.02980),
  GeoPoint(40.99090, 29.02350),
]);

/// Göztepe → Bostancı, given as an encoded polyline (e.g. from google_maps_polyline or the Routes API).
final _highwayRoute = SimRoute.fromEncodedPolyline(
  r'okdyF{yjpDjRs{@fOgw@bj@_cBf^w|And@giBnd@_cB',
);

class SimulatorScreen extends StatefulWidget {
  const SimulatorScreen({super.key});

  @override
  State<SimulatorScreen> createState() => _SimulatorScreenState();
}

class _SimulatorScreenState extends State<SimulatorScreen> {
  _Preset _preset = _Preset.city;
  bool _realistic = true;
  double _speedKmh = 40;
  double _noise = 4;
  bool _signalLoss = false;
  bool _jumps = false;
  bool _stopHalfway = true;
  bool _loop = false;
  double _rate = 5;

  late RouteSimulator _sim;
  bool _hasSim = false;
  StreamSubscription<SimulatedLocation>? _locSub;
  StreamSubscription<SimulatorState>? _stateSub;
  final _fixes = <SimulatedLocation>[];
  int _fixCount = 0;

  SimRoute get _route => _preset == _Preset.city ? _cityRoute : _highwayRoute;

  @override
  void initState() {
    super.initState();
    _rebuild();
  }

  @override
  void dispose() {
    _locSub?.cancel();
    _stateSub?.cancel();
    _sim.dispose();
    super.dispose();
  }

  /// Recreates the simulator after a setting changed.
  void _rebuild() {
    _locSub?.cancel();
    _stateSub?.cancel();
    if (_hasSim) _sim.dispose();
    final route = _route;
    final speed = _speedKmh / 3.6;
    _sim = RouteSimulator(
      route: route,
      profile: _realistic
          ? SpeedProfile.realistic(cruiseSpeed: speed)
          : SpeedProfile.constant(speed),
      stops: _stopHalfway
          ? [
              RouteStop.atDistance(
                route.length / 2,
                const Duration(seconds: 20),
              ),
            ]
          : const [],
      loop: _loop,
      noise: GpsNoise(
        horizontalError: _noise,
        altitudeError: _noise,
        seed: _seed,
        jumpProbability: _jumps ? 0.05 : 0,
        dropoutProbability: _signalLoss ? 0.15 : 0,
      ),
      playbackRate: _rate,
      defaultAltitude: 30,
    );
    _hasSim = true;
    _fixes.clear();
    _fixCount = 0;
    _locSub = _sim.locations.listen((l) {
      setState(() {
        _fixCount++;
        _fixes.add(l);
        if (_fixes.length > 600) _fixes.removeAt(0);
      });
    });
    _stateSub = _sim.states.listen((_) => setState(() {}));
  }

  void _change(VoidCallback update) {
    setState(() {
      update();
      _rebuild();
    });
  }

  Future<void> _exportGpx() async {
    final l = AppLocalizations.of(context);
    final points = _sim.generate(until: _loop ? _sim.lapDuration : null);
    final gpx = toGpx(points, name: l.appTitle);
    await showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(l.gpxDialogTitle(points.length)),
        content: SizedBox(
          width: 520,
          height: 320,
          child: SingleChildScrollView(
            child: SelectableText(
              gpx,
              style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text(l.close),
          ),
          FilledButton.icon(
            icon: const Icon(Icons.copy_rounded),
            label: Text(l.copy),
            onPressed: () async {
              await Clipboard.setData(ClipboardData(text: gpx));
              if (!context.mounted) return;
              Navigator.pop(context);
              ScaffoldMessenger.of(
                context,
              ).showSnackBar(SnackBar(content: Text(l.copied)));
            },
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    final sections = [
      _routeCard(l),
      _settingsCard(l),
      _controlsCard(l),
      _liveCard(l),
      _Section(
        title: l.trackTitle,
        subtitle: l.trackLegend,
        child: AspectRatio(
          aspectRatio: 1.4,
          child: CustomPaint(painter: _TrackPainter(_route, _fixes)),
        ),
      ),
      _Section(
        title: l.speedChartTitle,
        child: SizedBox(
          height: 160,
          child: CustomPaint(painter: _SpeedPainter(_fixes, _speedKmh)),
        ),
      ),
      _logCard(l),
    ];
    return Scaffold(
      appBar: AppBar(
        title: Text(l.appTitle),
        actions: [
          IconButton(
            tooltip: l.exportGpx,
            icon: const Icon(Icons.file_download_outlined),
            onPressed: _exportGpx,
          ),
        ],
      ),
      body: LayoutBuilder(
        builder: (context, c) {
          if (c.maxWidth < 900) {
            return ListView.separated(
              padding: const EdgeInsets.all(16),
              itemCount: sections.length,
              separatorBuilder: (_, _) => const SizedBox(height: 12),
              itemBuilder: (_, i) => sections[i],
            );
          }
          Widget column(List<Widget> children) => Expanded(
            child: ListView.separated(
              padding: const EdgeInsets.all(16),
              itemCount: children.length,
              separatorBuilder: (_, _) => const SizedBox(height: 12),
              itemBuilder: (_, i) => children[i],
            ),
          );
          return Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              column(sections.sublist(0, 3)),
              column(sections.sublist(3)),
            ],
          );
        },
      ),
    );
  }

  Widget _routeCard(AppLocalizations l) {
    final km = NumberFormat(
      '0.00',
      Localizations.localeOf(context).toString(),
    ).format(_route.length / 1000);
    return _Section(
      title: l.routeTitle,
      subtitle: l.routeSummary(
        km,
        _route.points.length,
        _formatDuration(_sim.lapDuration),
      ),
      child: SizedBox(
        width: double.infinity,
        child: SegmentedButton<_Preset>(
          segments: [
            ButtonSegment(
              value: _Preset.city,
              label: Text(l.presetCity),
              icon: const Icon(Icons.location_city),
            ),
            ButtonSegment(
              value: _Preset.highway,
              label: Text(l.presetHighway),
              icon: const Icon(Icons.route),
            ),
          ],
          selected: {_preset},
          onSelectionChanged: (s) => _change(() => _preset = s.first),
        ),
      ),
    );
  }

  Widget _settingsCard(AppLocalizations l) {
    Widget toggle(String label, bool value, ValueChanged<bool> onChanged) =>
        SwitchListTile(
          contentPadding: EdgeInsets.zero,
          dense: true,
          title: Text(label),
          value: value,
          onChanged: (v) => _change(() => onChanged(v)),
        );

    return _Section(
      title: l.settingsTitle,
      subtitle: l.seedLabel(_seed),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          SegmentedButton<bool>(
            segments: [
              ButtonSegment(value: false, label: Text(l.profileConstant)),
              ButtonSegment(value: true, label: Text(l.profileRealistic)),
            ],
            selected: {_realistic},
            onSelectionChanged: (s) => _change(() => _realistic = s.first),
          ),
          const SizedBox(height: 8),
          Text(l.speedLabel(_speedKmh.round())),
          Slider(
            value: _speedKmh,
            min: 10,
            max: 120,
            divisions: 22,
            label: '${_speedKmh.round()}',
            onChanged: (v) => setState(() => _speedKmh = v),
            onChangeEnd: (v) => _change(() => _speedKmh = v),
          ),
          Text(l.noiseLabel(_noise.round())),
          Slider(
            value: _noise,
            max: 20,
            divisions: 20,
            label: '${_noise.round()}',
            onChanged: (v) => setState(() => _noise = v),
            onChangeEnd: (v) => _change(() => _noise = v),
          ),
          toggle(l.signalLoss, _signalLoss, (v) => _signalLoss = v),
          toggle(l.jumps, _jumps, (v) => _jumps = v),
          toggle(l.stopHalfway, _stopHalfway, (v) => _stopHalfway = v),
          toggle(l.loop, _loop, (v) => _loop = v),
        ],
      ),
    );
  }

  Widget _controlsCard(AppLocalizations l) {
    final state = _sim.state;
    final lap = _sim.lapDuration;
    final elapsed = _sim.elapsed;
    final position = _loop
        ? Duration(
            microseconds:
                elapsed.inMicroseconds % math.max(1, lap.inMicroseconds),
          )
        : elapsed;
    final stateLabel = switch (state) {
      SimulatorState.idle => l.stateIdle,
      SimulatorState.running => l.stateRunning,
      SimulatorState.paused => l.statePaused,
      SimulatorState.finished => l.stateFinished,
    };
    return _Section(
      title: l.playback,
      trailing: Chip(
        label: Text(stateLabel),
        visualDensity: VisualDensity.compact,
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          SegmentedButton<double>(
            segments: const [
              ButtonSegment(value: 1, label: Text('1×')),
              ButtonSegment(value: 5, label: Text('5×')),
              ButtonSegment(value: 20, label: Text('20×')),
            ],
            selected: {_rate},
            onSelectionChanged: (s) => setState(() {
              _rate = s.first;
              _sim.playbackRate = _rate; // applied without restarting
            }),
          ),
          Slider(
            value: position.inMilliseconds
                .clamp(0, lap.inMilliseconds)
                .toDouble(),
            max: math.max(1, lap.inMilliseconds).toDouble(),
            onChanged: state == SimulatorState.idle
                ? null
                : (v) => setState(
                    () => _sim.seek(Duration(milliseconds: v.round())),
                  ),
          ),
          Row(
            children: [
              Text(
                _formatDuration(position),
                style: const TextStyle(color: _muted),
              ),
              const Spacer(),
              Text(_formatDuration(lap), style: const TextStyle(color: _muted)),
            ],
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              if (state == SimulatorState.running)
                FilledButton.icon(
                  onPressed: _sim.pause,
                  icon: const Icon(Icons.pause),
                  label: Text(l.pause),
                )
              else if (state == SimulatorState.paused)
                FilledButton.icon(
                  onPressed: _sim.resume,
                  icon: const Icon(Icons.play_arrow),
                  label: Text(l.resume),
                )
              else
                FilledButton.icon(
                  onPressed: () {
                    setState(_fixes.clear);
                    _sim.start();
                  },
                  icon: const Icon(Icons.play_arrow),
                  label: Text(l.start),
                ),
              OutlinedButton.icon(
                onPressed: state == SimulatorState.idle ? null : _sim.stop,
                icon: const Icon(Icons.stop),
                label: Text(l.stop),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _liveCard(AppLocalizations l) {
    final last = _fixes.isEmpty ? null : _fixes.last;
    if (last == null) {
      return _Section(
        title: l.liveTitle,
        child: Text(l.noFixYet, style: const TextStyle(color: _muted)),
      );
    }
    final n = NumberFormat('0.0', Localizations.localeOf(context).toString());
    return _Section(
      title: l.liveTitle,
      subtitle:
          '${last.latitude.toStringAsFixed(6)}, ${last.longitude.toStringAsFixed(6)}',
      child: Wrap(
        spacing: 12,
        runSpacing: 12,
        children: [
          _Stat(l.speed, '${n.format(last.speed * 3.6)} km/h', Icons.speed),
          _Stat(l.accuracy, '±${n.format(last.accuracy)} m', Icons.gps_fixed),
          _Stat(l.heading, '${last.heading.round()}°', Icons.explore_outlined),
          _Stat(l.distance, '${n.format(last.distance)} m', Icons.straighten),
          _Stat(l.lap, '${last.lap + 1}', Icons.repeat),
          _Stat(l.fixes, '$_fixCount', Icons.place_outlined),
        ],
      ),
    );
  }

  Widget _logCard(AppLocalizations l) {
    final recent = _fixes.reversed.take(12).toList();
    return _Section(
      title: l.logTitle,
      child: recent.isEmpty
          ? Text(l.noFixYet, style: const TextStyle(color: _muted))
          : Column(
              children: [
                for (final f in recent)
                  ListTile(
                    dense: true,
                    contentPadding: EdgeInsets.zero,
                    leading: Text(
                      _formatDuration(f.elapsed),
                      style: const TextStyle(
                        fontFeatures: [FontFeature.tabularFigures()],
                      ),
                    ),
                    title: Text(
                      '${f.latitude.toStringAsFixed(6)}, ${f.longitude.toStringAsFixed(6)}',
                    ),
                    subtitle: Text(
                      '${(f.speed * 3.6).toStringAsFixed(1)} km/h · ±${f.accuracy.toStringAsFixed(1)} m',
                    ),
                    trailing: f.isJump
                        ? _Tag(l.tagJump, Colors.red.shade700)
                        : f.isStopped
                        ? _Tag(l.tagStopped, Colors.orange.shade800)
                        : null,
                  ),
              ],
            ),
    );
  }
}

String _formatDuration(Duration d) {
  final m = d.inMinutes.toString().padLeft(2, '0');
  final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
  return '$m:$s';
}

class _Section extends StatelessWidget {
  const _Section({
    required this.title,
    this.subtitle,
    this.trailing,
    required this.child,
  });
  final String title;
  final String? subtitle;
  final Widget? trailing;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(
                  child: Text(
                    title,
                    style: theme.textTheme.titleMedium?.copyWith(
                      fontWeight: FontWeight.w600,
                      color: _brandNavy,
                    ),
                  ),
                ),
                ?trailing,
              ],
            ),
            if (subtitle != null) ...[
              const SizedBox(height: 2),
              Text(
                subtitle!,
                style: theme.textTheme.bodySmall?.copyWith(color: _muted),
              ),
            ],
            const SizedBox(height: 12),
            child,
          ],
        ),
      ),
    );
  }
}

class _Stat extends StatelessWidget {
  const _Stat(this.label, this.value, this.icon);
  final String label;
  final String value;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 150,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFFF8FAFC),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: _border),
      ),
      child: Row(
        children: [
          Icon(icon, size: 20, color: _brandBlue),
          const SizedBox(width: 8),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  label,
                  style: const TextStyle(fontSize: 11, color: _muted),
                ),
                Text(
                  value,
                  style: const TextStyle(fontWeight: FontWeight.w600),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _Tag extends StatelessWidget {
  const _Tag(this.label, this.color);
  final String label;
  final Color color;

  @override
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
    decoration: BoxDecoration(
      color: color.withValues(alpha: 0.1),
      borderRadius: BorderRadius.circular(8),
    ),
    child: Text(
      label,
      style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600),
    ),
  );
}

/// Plots the route and the fixes in a local equirectangular projection (no map needed).
class _TrackPainter extends CustomPainter {
  _TrackPainter(this.route, this.fixes) : _count = fixes.length;
  final SimRoute route;
  final List<SimulatedLocation> fixes;
  final int _count;

  @override
  void paint(Canvas canvas, Size size) {
    final pts = route.points;
    var minLat = pts.map((p) => p.latitude).reduce(math.min);
    var maxLat = pts.map((p) => p.latitude).reduce(math.max);
    var minLng = pts.map((p) => p.longitude).reduce(math.min);
    var maxLng = pts.map((p) => p.longitude).reduce(math.max);
    final pad = 0.08 * math.max(maxLat - minLat, maxLng - minLng);
    minLat -= pad;
    maxLat += pad;
    minLng -= pad;
    maxLng += pad;
    final kx = math.cos((minLat + maxLat) / 2 * math.pi / 180);
    final w = (maxLng - minLng) * kx;
    final h = maxLat - minLat;
    final scale = math.min(size.width / w, size.height / h);
    final ox = (size.width - w * scale) / 2;
    final oy = (size.height - h * scale) / 2;
    Offset project(double lat, double lng) =>
        Offset(ox + (lng - minLng) * kx * scale, oy + (maxLat - lat) * scale);

    final routePath = Path();
    for (var i = 0; i < pts.length; i++) {
      final o = project(pts[i].latitude, pts[i].longitude);
      i == 0 ? routePath.moveTo(o.dx, o.dy) : routePath.lineTo(o.dx, o.dy);
    }
    canvas.drawPath(
      routePath,
      Paint()
        ..color = const Color(0xFFCBD5E1)
        ..style = PaintingStyle.stroke
        ..strokeWidth = 6
        ..strokeJoin = StrokeJoin.round
        ..strokeCap = StrokeCap.round,
    );

    final dot = Paint()..color = _brandBlue.withValues(alpha: 0.55);
    final jump = Paint()..color = Colors.red.shade600;
    for (final f in fixes) {
      canvas.drawCircle(
        project(f.latitude, f.longitude),
        f.isJump ? 4 : 2.5,
        f.isJump ? jump : dot,
      );
    }
    if (fixes.isNotEmpty) {
      final last = project(fixes.last.latitude, fixes.last.longitude);
      canvas.drawCircle(last, 9, Paint()..color = Colors.white);
      canvas.drawCircle(last, 7, Paint()..color = _brandNavy);
    }
  }

  @override
  bool shouldRepaint(_TrackPainter old) =>
      old.route != route || old._count != _count || fixes.isNotEmpty;
}

/// Speed (km/h) of the recent fixes as a line chart.
class _SpeedPainter extends CustomPainter {
  _SpeedPainter(this.fixes, this.cruiseKmh);
  final List<SimulatedLocation> fixes;
  final double cruiseKmh;

  @override
  void paint(Canvas canvas, Size size) {
    final maxKmh = math.max(cruiseKmh * 1.25, 10);
    final grid = Paint()
      ..color = _border
      ..strokeWidth = 1;
    for (var i = 0; i <= 4; i++) {
      final y = size.height * i / 4;
      canvas.drawLine(Offset(0, y), Offset(size.width, y), grid);
    }
    final cruiseY = size.height * (1 - cruiseKmh / maxKmh);
    canvas.drawLine(
      Offset(0, cruiseY),
      Offset(size.width, cruiseY),
      Paint()
        ..color = _brandNavy.withValues(alpha: 0.35)
        ..strokeWidth = 1.5,
    );
    final tp = TextPainter(
      text: TextSpan(
        text: '${cruiseKmh.round()} km/h',
        style: const TextStyle(fontSize: 10, color: _muted),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(canvas, Offset(4, math.max(0, cruiseY - tp.height - 2)));

    final recent = fixes.length > 120
        ? fixes.sublist(fixes.length - 120)
        : fixes;
    if (recent.length < 2) return;
    final path = Path();
    for (var i = 0; i < recent.length; i++) {
      final x = size.width * i / 119;
      final y =
          size.height * (1 - (recent[i].speed * 3.6 / maxKmh).clamp(0.0, 1.0));
      i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
    }
    canvas.drawPath(
      path,
      Paint()
        ..color = _brandBlue
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..strokeJoin = StrokeJoin.round,
    );
  }

  @override
  bool shouldRepaint(_SpeedPainter old) => true;
}
0
likes
160
points
0
downloads

Documentation

API reference

Publisher

verified publisherabdurrahmankaraoglu.com

Weekly Downloads

Pure Dart GPS route simulator: realistic fake location streams with speed profiles, stops, seeded noise, signal loss, a virtual clock, GPX export and emulator playback.

Repository (GitHub)
View/report issues

Topics

#gps #location #simulation #testing #mock

License

MIT (license)

More

Packages that depend on route_simulator