Skip to content

Examples

Basic tracking

import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg;

// Use the 'bg' namespace to avoid conflicts with Flutter's own Location/State types.
bg.BackgroundGeolocation.ready(bg.Config(
  geolocation: bg.GeoConfig(
    desiredAccuracy: bg.DesiredAccuracy.high,
    distanceFilter: 10.0,
  ),
  app: bg.AppConfig(
    stopOnTerminate: false,
    startOnBoot: true,
  ),
  logger: bg.LoggerConfig(
    debug: true,
    logLevel: bg.LogLevel.verbose,
  ),
)).then((bg.State state) {
  if (!state.enabled) {
    bg.BackgroundGeolocation.start();
  }
});

Generate a demo app

Paste the following prompt into your AI coding agent (Claude Code, Cursor, Copilot, Codex …) from an empty directory. It interviews you first, then scaffolds the app, applies every piece of native configuration, and builds a working tracking UI.

Create a working Flutter demo app for the Transistor Software Background
Geolocation SDK (`flutter_background_geolocation`), then build and run it.

## Step 1 β€” Ask me first

Before writing any code, ask me these questions and wait for my answers. Offer
the default in brackets so I can just say "defaults".

1. **App name?** [`bggeo_flutter`] β€” and the **org** for the bundle id
   [`com.example`].
2. **Which platforms should I build and run?** [both iOS and Android]
   β€” For iOS I need Xcode. For Android I need a connected device or running
   emulator, **and `ANDROID_HOME` exported**
   (`export ANDROID_HOME=$HOME/Library/Android/sdk`) or `sdk.dir` set in
   `android/local.properties`.
3. **Do you have a license key?** [no]
   β€” The SDK is fully functional in **debug** builds without one. A key is only
   required for release builds. A `LICENSE VALIDATION FAILURE` block appears on
   every debug launch β€” in `adb logcat` and the Xcode console, **not** in the
   build output you are watching. It is expected and harmless, and the message
   itself says so.

## Step 2 β€” Non-negotiable API rules

These override anything you may have seen in older tutorials, blog posts or
StackOverflow answers. Most produce code that does not compile, or β€” worse β€”
compiles and behaves wrongly. Rule 2 is the exception: the flat config options
still compile, but they are `@Deprecated` and are the single most common source
of stale, copy-pasted v4 code.

1. **Import under the `bg` namespace.** Always:
   ```dart
   import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg;
   ```
   The SDK exports `State` and `Location`, which collide with Flutter's own
   `State<T>` and other packages. Every SDK symbol below is `bg.`-prefixed.
2. **Use the v5 "Compound Config" object.** Config is grouped by domain β€”
   `bg.GeoConfig`, `bg.AppConfig`, `bg.HttpConfig`, `bg.LoggerConfig`, … Never
   write the pre-v5 flat form
   `bg.Config(desiredAccuracy: ..., distanceFilter: ..., stopOnTerminate: ...)`.
3. **Use the Dart enums, not the legacy `SCREAMING_CASE` constants.** Write
   `bg.DesiredAccuracy.high` and `bg.LogLevel.verbose` β€” lowerCamelCase, as Dart
   enums are. Do NOT write `bg.Config.DESIRED_ACCURACY_HIGH` or
   `bg.Config.LOG_LEVEL_VERBOSE`.
   **This rule applies to the config object only.**
   `bg.BackgroundGeolocation.getCurrentPosition()` takes
   `desiredAccuracy` as a **`double` of metres** β€” passing `10.0` there is
   correct; passing the enum is a different type entirely.
4. **`onMotionChange` delivers a `bg.Location`, not a motion-change event.**
   Read `location.isMoving` from it. (The JavaScript SDKs deliver a dedicated
   `MotionChangeEvent` β€” Dart does not.)
5. **`bg.State` extends `bg.Config`**, so it carries the whole resolved
   configuration alongside its own runtime fields (`enabled`, `odometer`,
   `trackingMode`, `schedulerEnabled`, `didLaunchInBackground`, `isFirstBoot`,
   `didDeviceReboot`). That means `state.geolocation.distanceFilter` and friends
   are readable from a returned `State`. Note `state.isMoving` is inherited from
   `Config` and is therefore **nullable** (`bool?`) β€” assign it to a `bool?`, and
   render an "unknown" case rather than forcing it with `!`.
6. **Do NOT add anything to `AppDelegate.swift`.** The SDK's `TSBackgroundFetch`
   dependency installs itself via an Objective-C `+load` and observes
   `UIApplicationDidFinishLaunchingNotification`. Any instruction telling you to
   call `TSBackgroundFetch.sharedInstance().didFinishLaunching()` is obsolete.
7. **Register event listeners BEFORE calling `ready()`**, call `ready()` exactly
   once per app launch, and `remove()` every `bg.Subscription` in `dispose()`.
   `ready()` does not start tracking β€” `start()` does.

## Where to consult the API

If you need a name, type or default this prompt does not spell out, consult
these in order β€” do **not** guess from memory, and do not assume this SDK
matches its React Native / Capacitor siblings, which differ in several places:

1. **The official API reference β€” the authoritative documentation**, one page per
   symbol: <https://docs.transistorsoft.com/flutter/> β€” e.g. `/flutter/GeoConfig/`,
   `/flutter/Config/`, `/flutter/Location/`, `/flutter/BackgroundGeolocation/`,
   `/flutter/DesiredAccuracy/`. Prefer this for behaviour, defaults and examples.
2. **The installed package source**, for *signatures and types only* β€” it is the
   actual code you compile against, so it is definitive about what exists and
   what type it is. Locate it with:
   ```bash
   python3 -c "import json;print([p['rootUri'] for p in json.load(open('.dart_tool/package_config.json'))['packages'] if p['name']=='flutter_background_geolocation'][0])"
   ```
   Models live under `lib/models/` β€” `config/geo_config.dart`,
   `config/app_config.dart`, `config/logger_config.dart`, `location.dart`,
   `state.dart`, `background_geolocation.dart`.
   **Do not copy the dartdoc code samples from that source.** They are not the
   maintained documentation and some still show pre-v5 constants that no longer
   compile. Read the declarations, not the comments.

When checking whether a field exists on a class, remember Dart inheritance:
`State extends Config`, so many fields are inherited rather than declared in
`state.dart`. Grepping a single file is not proof of absence β€” follow the
`extends` chain.

Fastest tiebreaker: write the line and run `flutter analyze`. It settles any
question about what compiles more reliably than reasoning about the types.

## Step 3 β€” Scaffold and install

```bash
flutter create --org <org> --platforms=ios,android <app_name>
cd <app_name>
flutter pub add flutter_background_geolocation
```

`flutter create` also writes `test/widget_test.dart`, which references the
template's `MyApp` widget. Once you replace `lib/main.dart` in Step 4 that test
no longer compiles and `flutter analyze` fails. Delete it (or rewrite it against
the new root widget).

## Step 4 β€” Native configuration

Apply these exactly as written. They are transcribed from the official
[Flutter Setup guide](https://docs.transistorsoft.com/flutter/setup/) β€” do not
substitute values from memory or from other versions of this SDK.

**Skip the Gradle `ext` vars.** The plugin already depends on tested, compatible
versions; leaving them unset applies the defaults, which is right for this demo.

### iOS β€” dependency manager

**Prefer Swift Package Manager.** Follow this:

The plugin ships a `Package.swift`, so Flutter can resolve it through Swift
Package Manager instead of CocoaPods β€” no Podfile, no `pod install`.

Swift Package Manager is a **machine-wide** Flutter setting, not per-project.
Enable it and confirm:

```bash
flutter config --enable-swift-package-manager
flutter config --list | grep swift-package-manager
```

Because the setting is machine-wide it affects your other Flutter projects too;
undo it with `flutter config --no-enable-swift-package-manager`.

With SPM active, Flutter generates **no `ios/Podfile`** and the
`:linkage => :static` step does not apply. Do not hand-write a Podfile.

Xcode resolves two Swift packages at build time. Allowlist these hosts if you
build behind a proxy or firewall:

| Package | Minimum |
|---|---|
| [`transistorsoft/native-background-geolocation`](https://github.com/transistorsoft/native-background-geolocation) | `4.4.0` |
| [`transistorsoft/transistor-background-fetch`](https://github.com/transistorsoft/transistor-background-fetch) | `4.0.5` |

Both managers can be active at once: an existing project keeps using CocoaPods
for any plugin that does not ship a `Package.swift`.

Verify there is genuinely no Podfile before moving on:

```bash
flutter build ios --config-only --simulator
ls ios/Podfile          # expect: no such file
```

Only if an `ios/Podfile` *does* appear (SPM unavailable to you), use the
CocoaPods path instead β€” on that path `:linkage => :static` is required:

Add `:linkage => :static` to your target in `ios/Podfile`:

```ruby
target 'Runner' do
  use_frameworks! :linkage => :static
  # ...
end
```

Then run:

```bash
cd ios && pod install
```

### iOS β€” `ios/Runner/Info.plist`

**Add or replace** these keys β€” do not blindly append, or you may end up with a
duplicate key in the same dict. Omit `TSLocationManagerLicense` if I have no key:

```xml
<!-- License key -->
<key>TSLocationManagerLicense</key>
<string>YOUR_LICENSE_KEY_JWT</string>

<!-- Background modes -->
<key>UIBackgroundModes</key>
<array>
    <string>location</string>
    <string>fetch</string>
    <string>processing</string>
    <string>audio</string>
</array>

<!-- Background task identifier -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.transistorsoft.fetch</string>
</array>

<!-- Location usage descriptions -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>App requires location access at all times for background tracking.</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>App requires location access while in use.</string>

<key>NSMotionUsageDescription</key>
<string>Motion detection helps determine when the device is stationary.</string>
```

Before shipping to the App Store, remove `audio` from `UIBackgroundModes` unless
your app genuinely plays audio β€” an unused background mode is a common review
rejection. It is here so you can *hear* the SDK's debug sound FX while the app is
backgrounded (`logger.debug: true` in the app config).

`audio` is what lets you *hear* the SDK's debug sound FX while the app is
backgrounded (`logger.debug: true` in the app config). Validate the file with
`plutil -lint ios/Runner/Info.plist` before building.

### Android β€” `android/app/build.gradle[.kts]`

Recent Flutter scaffolds the Kotlin DSL (`build.gradle.kts`); older projects use
Groovy. Apply the matching form. **Merge the `release { }` settings into the
block `flutter create` already generated inside `android { }`** β€” do not add a
second `buildTypes` block, and put the `apply` above `android { }`.

Kotlin DSL β€” `android/app/build.gradle.kts`:

```kotlin
val backgroundGeolocation = project(":flutter_background_geolocation")
apply { from("${backgroundGeolocation.projectDir}/background_geolocation.gradle") }

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = false   // required
        }
    }
}
```

Groovy β€” `android/app/build.gradle`:

```groovy
Project background_geolocation = project(':flutter_background_geolocation')
apply from: "${background_geolocation.projectDir}/background_geolocation.gradle"

android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources false   // required
        }
    }
}
```

### Android β€” `android/app/src/main/AndroidManifest.xml`

Only if I gave you a license key, add inside `<application>`:

```xml
<application>
    <meta-data
        android:name="com.transistorsoft.locationmanager.license"
        android:value="YOUR_LICENSE_KEY_JWT" />
</application>
```


## Step 5 β€” Write the app

Replace `lib/main.dart` with **exactly** the following file. Use it verbatim β€”
do not "modernise" the config or swap the enums for the legacy constants.

```dart
/// Background Geolocation demo.
///
/// - Switch  -> bg.BackgroundGeolocation.start() / .stop()
/// - Button  -> bg.BackgroundGeolocation.getCurrentPosition()
/// - onMotionChange -> renders the current `isMoving` state
/// - onLocation     -> renders the location in a JSON panel
library;

import 'dart:convert';
import 'dart:io' show Platform;

import 'package:flutter/material.dart';

// The `bg` prefix is idiomatic for this SDK: several of its types (`State`,
// `Location`) would otherwise collide with Flutter's own.
import 'package:flutter_background_geolocation/flutter_background_geolocation.dart'
    as bg;

void main() => runApp(const DemoApp());

const Color kHeader = Color(0xFFFEDD1E);
const Color kGreen = Color(0xFF16BE42);
const Color kRed = Color(0xFFFE381E);
const Color kGrey = Color(0xFF777777);
const Color kBlue = Color(0xFF337AB7);
const Color kDark = Color(0xFF1A1A1A);

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'Background Geolocation',
        debugShowCheckedModeBanner: false,
        home: const HomePage(),
      );
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final List<bg.Subscription> _subscriptions = [];

  bool _ready = false;
  bool _enabled = false;
  bool? _isMoving;
  bg.Location? _location;
  double _odometer = 0;
  int _locationCount = 0;
  bool _busy = false;
  String? _error;

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

  Future<void> _initPlatformState() async {
    /// 1. Register event-listeners BEFORE calling .ready().
    _subscriptions.add(bg.BackgroundGeolocation.onLocation((bg.Location location) {
      debugPrint('[onLocation] $location');
      setState(() {
        _error = null; // a good fix supersedes a stale error
        _location = location;
        _odometer = location.odometer;
        _locationCount++;
      });
    }, (bg.LocationError error) {
      debugPrint('[onLocation] ERROR: $error');
      setState(() => _error = 'onLocation error ${error.code}: ${error.message}');
    }));

    // NOTE: Flutter's onMotionChange delivers a `Location` β€” read `isMoving`
    // from it.  (Other platform SDKs deliver a dedicated MotionChangeEvent.)
    _subscriptions.add(bg.BackgroundGeolocation.onMotionChange((bg.Location location) {
      debugPrint('[onMotionChange] $location');
      setState(() {
        _isMoving = location.isMoving;
        _location = location;
      });
    }));

    /// 2. Configure the SDK.  .ready() is called exactly once per app-launch.
    ///    v5 uses the "Compound Config" structure (grouped by domain).
    try {
      final bg.State state = await bg.BackgroundGeolocation.ready(bg.Config(
        geolocation: bg.GeoConfig(
          desiredAccuracy: bg.DesiredAccuracy.high,
          distanceFilter: 10.0,
          stopTimeout: 5,
        ),
        app: bg.AppConfig(
          stopOnTerminate: false,
          startOnBoot: true,
          // Android 11+ shows this before asking for "Allow all the time".
          // Without it the SDK falls back to a "[CHANGEME]" placeholder.
          backgroundPermissionRationale: bg.PermissionRationale(
            title: 'Allow background location access?',
            message: 'This app records your location in the background to '
                'demonstrate the SDK.',
            positiveAction: 'Change to "Allow all the time"',
            negativeAction: 'Cancel',
          ),
        ),
        logger: bg.LoggerConfig(
          // Audible sound FX + a persistent notification.  Turn OFF in production.
          debug: true,
          logLevel: bg.LogLevel.verbose,
        ),
      ));
      if (!mounted) return;
      setState(() {
        // `state` reflects tracking state persisted across app launches.
        // bg.State extends bg.Config, so `isMoving` is inherited β€” and nullable.
        _enabled = state.enabled;
        _isMoving = state.isMoving;
        _odometer = state.odometer;
        _ready = true;
      });
    } catch (err) {
      if (!mounted) return;
      setState(() {
        _error = 'ready() error: $err';
        _ready = true;
      });
    }
  }

  @override
  void dispose() {
    /// 3. Tear down on unmount.
    for (final sub in _subscriptions) {
      sub.remove();
    }
    _subscriptions.clear();
    super.dispose();
  }

  Future<void> _onToggleEnabled(bool value) async {
    setState(() {
      _error = null;
      _enabled = value; // optimistic β€” reconciled with the returned State below.
    });
    try {
      if (value) {
        final bg.State state = await bg.BackgroundGeolocation.start();
        if (!mounted) return;
        setState(() => _enabled = state.enabled);
      } else {
        final bg.State state = await bg.BackgroundGeolocation.stop();
        if (!mounted) return;
        setState(() {
          _enabled = state.enabled;
          _isMoving = null;
          _location = null;
          _locationCount = 0;
        });
      }
    } catch (err) {
      if (!mounted) return;
      setState(() {
        _error = '${value ? 'start' : 'stop'}() error: $err';
        _enabled = !value; // roll back the optimistic update.
      });
    }
  }

  Future<void> _onGetCurrentPosition() async {
    setState(() {
      _error = null;
      _busy = true;
    });
    try {
      final bg.Location location = await bg.BackgroundGeolocation.getCurrentPosition(
        samples: 2,
        timeout: 30,
        maximumAge: 0,
        // NOTE: metres here β€” NOT the bg.DesiredAccuracy enum.
        desiredAccuracy: 10.0,
        extras: {'event': 'getCurrentPosition'},
      );
      if (!mounted) return;
      setState(() => _location = location);
    } catch (err) {
      if (!mounted) return;
      setState(() => _error = 'getCurrentPosition error: $err');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final Color motionColor = _isMoving == null
        ? kGrey
        : (_isMoving! ? kGreen : kRed);

    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        bottom: false,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // ---------- Header ----------
            Container(
              color: kHeader,
              padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  const Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text('Background Geolocation',
                          style: TextStyle(
                              fontSize: 18, fontWeight: FontWeight.bold)),
                      SizedBox(height: 2),
                      Text('flutter_background_geolocation',
                          style: TextStyle(fontSize: 11, color: Color(0xFF5A5000))),
                    ],
                  ),
                  Row(mainAxisSize: MainAxisSize.min, children: [
                    Text(_enabled ? 'ON' : 'OFF',
                        style: const TextStyle(
                            fontSize: 12, fontWeight: FontWeight.bold)),
                    const SizedBox(width: 8),
                    Switch(
                      value: _enabled,
                      onChanged: _ready ? _onToggleEnabled : null,
                      activeTrackColor: const Color(0xFF8BC34A),
                      activeThumbColor: kGreen,
                    ),
                  ]),
                ],
              ),
            ),

            if (!_ready)
              Container(
                color: const Color(0xFFEEF4FA),
                padding: const EdgeInsets.symmetric(vertical: 8),
                child: const Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SizedBox(
                        width: 16,
                        height: 16,
                        child: CircularProgressIndicator(strokeWidth: 2)),
                    SizedBox(width: 8),
                    Text('Configuring SDK…',
                        style: TextStyle(color: kBlue, fontSize: 13)),
                  ],
                ),
              ),

            // ---------- isMoving (onMotionChange) ----------
            Container(
              color: motionColor,
              padding: const EdgeInsets.symmetric(vertical: 18),
              child: Column(children: [
                const Text('ONMOTIONCHANGE Β· ISMOVING',
                    style: TextStyle(
                        color: Colors.white70, fontSize: 11, letterSpacing: 1)),
                const SizedBox(height: 2),
                Text(
                  _isMoving == null
                      ? 'β€”'
                      : (_isMoving! ? 'MOVING' : 'STATIONARY'),
                  style: const TextStyle(
                      color: Colors.white,
                      fontSize: 30,
                      fontWeight: FontWeight.w800,
                      letterSpacing: 1),
                ),
              ]),
            ),

            // ---------- Stats ----------
            Container(
              decoration: const BoxDecoration(
                  border: Border(
                      bottom: BorderSide(color: Color(0xFFDDDDDD), width: 0.5))),
              child: Row(children: [
                _Stat(label: 'onLocation', value: '$_locationCount'),
                _Stat(
                    label: 'odometer',
                    value: '${(_odometer / 1000).toStringAsFixed(2)} km'),
                _Stat(label: 'state', value: _enabled ? 'tracking' : 'stopped'),
              ]),
            ),

            // ---------- getCurrentPosition ----------
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
              child: SizedBox(
                height: 48,
                child: ElevatedButton(
                  onPressed: (!_ready || _busy) ? null : _onGetCurrentPosition,
                  style: ElevatedButton.styleFrom(
                    backgroundColor: kBlue,
                    foregroundColor: Colors.white,
                    disabledBackgroundColor: const Color(0xFFAAAAAA),
                    shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(8)),
                  ),
                  child: _busy
                      ? const SizedBox(
                          width: 20,
                          height: 20,
                          child: CircularProgressIndicator(
                              strokeWidth: 2, color: Colors.white))
                      : const Text('getCurrentPosition',
                          style: TextStyle(
                              fontSize: 16, fontWeight: FontWeight.w600)),
                ),
              ),
            ),

            if (_error != null)
              Container(
                margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
                padding: const EdgeInsets.all(10),
                decoration: BoxDecoration(
                  color: const Color(0xFFFDECEA),
                  borderRadius: BorderRadius.circular(6),
                  border: Border.all(color: kRed, width: 0.5),
                ),
                child: Text(_error!,
                    style: const TextStyle(color: Color(0xFFA8261A), fontSize: 12)),
              ),

            // ---------- JSON panel (onLocation) ----------
            const Padding(
              padding: EdgeInsets.fromLTRB(16, 4, 16, 6),
              child: Text('LOCATION JSON',
                  style: TextStyle(
                      fontSize: 11, letterSpacing: 1, color: Color(0xFF888888))),
            ),
            Expanded(child: _JsonPanel(location: _location)),
          ],
        ),
      ),
    );
  }
}

/// Rendered as its own widget so a fast stream of `onLocation` events repaints
/// only this panel.
class _JsonPanel extends StatelessWidget {
  const _JsonPanel({required this.location});

  final bg.Location? location;

  @override
  Widget build(BuildContext context) {
    // `toMap()` returns the raw Map the native SDK delivered.
    final String json = location == null
        ? '// Waiting for a location…\n// Toggle tracking ON or tap getCurrentPosition.'
        : const JsonEncoder.withIndent('  ').convert(location!.toMap());

    return Container(
      margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
      decoration: BoxDecoration(
          color: kDark, borderRadius: BorderRadius.circular(8)),
      child: SingleChildScrollView(
        padding: const EdgeInsets.all(12),
        child: SelectableText(
          json,
          style: TextStyle(
            color: const Color(0xFF8FEF9F),
            fontSize: 12,
            height: 1.4,
            // Flutter resolves 'monospace' on Android; iOS needs a real face.
            fontFamily: Platform.isIOS ? 'Menlo' : 'monospace',
          ),
        ),
      ),
    );
  }
}

class _Stat extends StatelessWidget {
  const _Stat({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) => Expanded(
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 12),
          child: Column(children: [
            Text(value,
                style: const TextStyle(
                    fontSize: 18, fontWeight: FontWeight.bold, color: kDark)),
            const SizedBox(height: 2),
            Text(label,
                style: const TextStyle(fontSize: 10, color: Color(0xFF888888))),
          ]),
        ),
      );
}
```

Notes on why it is written this way, so you don't refactor the behaviour out:

- Listeners are registered **before** `ready()` and removed in `dispose()`.
- `start()` / `stop()` each return a `bg.State`; the switch is reconciled from
  `state.enabled` rather than assuming success, and rolls back on throw.
- Every `setState` after an `await` is guarded by `if (!mounted) return;`.
- The JSON panel renders `location.toMap()` β€” the raw map from native β€” via
  `JsonEncoder.withIndent`, and lives in its own widget so the location stream
  repaints only that panel.
- The monospace font is `Platform.isIOS ? 'Menlo' : 'monospace'`; Flutter
  resolves `'monospace'` on Android but not on iOS.


`logger.debug: true` also asks for **notification permission** on iOS and posts
on-screen debug notifications ("πŸ”΄ Location-services OFF"). That prompt is
expected β€” it is the debug logger, not a bug.

## Step 6 β€” Verify

Analyze, then build and run on the platforms I chose. Do not report success
until the app actually launches:

```bash
flutter analyze
flutter devices          # copy the id of the device you want
flutter run -d <device-id>
```

There is no `android` or `ios` device alias β€” `-d` needs a real **device id**
from `flutter devices`. Use the id, not the name: booted simulators and attached
phones frequently share a model name (two `iPhone 17 Pro` entries is normal), and
`flutter run -d "iPhone 17 Pro"` will silently pick one of them rather than
warn you, so you can end up installing onto a device you are not watching.

Confirm at runtime that toggling the switch starts and stops tracking, that
`getCurrentPosition` returns a location, and that the `isMoving` and JSON panels
update.

On the iOS Simulator, choose **Features β†’ Location β†’ Freeway Drive** to generate
movement β€” without a location scenario the SDK reports
`onLocation error 0: LOCATION_ERROR` ("Location unknown"), which is the
simulator having no fix, not a bug. The scenario stops when cleared or when the
simulator restarts, so re-arm it if locations stop arriving. Simulated fixes
carry `"mock": true`.

If you grant only **"Allow While Using App"**, you will be prompted again to
upgrade to "Always" β€” on modern iOS the system presents its own in-app sheet
("…also use your location even when you are not using the app?"), while older
versions show an SDK alert that opens Settings. Either way, choose Always.

On the Android Emulator, push a location from **Extended Controls β†’ Location**,
or `adb emu geo fix <longitude> <latitude>`. With no fix the JSON panel stays
empty. Moving the emulator far enough with `geo fix` exits the SDK's stationary
geofence and *does* fire `onMotionChange`, so `isMoving` will flip to MOVING β€”
but the `activity` field stays `unknown`, since activity recognition itself
cannot be simulated.

Simulator caveats

activity.type is always "unknown" with confidence: 0 on the iOS Simulator β€” CoreMotion's activity classifier is unavailable there. Test motion-activity behaviour on a physical device, where you will see real values such as "still" with confidence: 100.