baaba_location 0.1.0 copy "baaba_location: ^0.1.0" to clipboard
baaba_location: ^0.1.0 copied to clipboard

Foreground location for Flutter that never throws: one call runs permission, services, fix, fallback and address lookup, with filters, a setup doctor and a test double.

example/lib/main.dart

import 'dart:async';

import 'package:baaba_location/baaba_location.dart';
import 'package:baaba_permission_handler/baaba_permission_handler.dart'
    show AppPermission, BaabaPermissions;
import 'package:flutter/material.dart';

import 'log_observer.dart';
import 'nominatim_provider.dart';

final log = LogObserver();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Optional: everything works without init, with defaults.
  await BaabaLocation.init(
    LocationConfig(
      observer: log,
      iosPrecisePurposeKey: 'Navigation',
      geocoding: GeocodingConfig(
        // The platform geocoder first; Nominatim when it is missing,
        // offline or throttling.
        fallbackProviders: [NominatimProvider()],
      ),
    ),
  );
  runApp(const ExampleApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'baaba_location',
    theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
    home: const HomePage(),
  );
}

BaabaLocation get location => BaabaLocation.instance;

String age(Duration? d) => d == null
    ? '—'
    : d.inMinutes >= 1
    ? '${d.inMinutes} min'
    : '${d.inSeconds} s';

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  LocationProfile profile = LocationProfile.checkIn;
  Locale locale = const Locale('en');

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('baaba_location')),
    // A Column, not a ListView: a ListView disposes the sections scrolled out
    // of view, and they lose their results.
    body: SingleChildScrollView(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          _Section('Profile and language', [
            Wrap(
              spacing: 8,
              children: [
                for (final p in LocationProfile.presets)
                  ChoiceChip(
                    label: Text(p.name),
                    selected: profile == p,
                    onSelected: (_) => setState(() => profile = p),
                  ),
              ],
            ),
            const SizedBox(height: 8),
            SegmentedButton<Locale>(
              segments: const [
                ButtonSegment(value: Locale('en'), label: Text('English')),
                ButtonSegment(value: Locale('ur', 'PK'), label: Text('اردو')),
              ],
              selected: {locale},
              onSelectionChanged: (s) => setState(() => locale = s.first),
            ),
          ]),
          GetSection(profile: profile, locale: locale),
          _Section('LocationBuilder', [
            LocationBuilder(
              key: ValueKey((profile, locale)),
              profile: profile,
              locale: locale,
              withAddress: true,
              builder: (context, r) =>
                  Text(r.address?.formatted ?? r.position.toString()),
            ),
          ]),
          const LastKnownSection(),
          GeocodingSection(locale: locale),
          StreamSection(profile: profile),
          const StateSection(),
          const LogSection(),
        ],
      ),
    ),
  );
}

class _Section extends StatelessWidget {
  const _Section(this.title, this.children);
  final String title;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) => Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: Padding(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(title, style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 8),
          ...children,
        ],
      ),
    ),
  );
}

/// Rows of a result, success or failure.
class ResultView extends StatelessWidget {
  const ResultView(this.result, {super.key, this.onResolve});
  final LocationResult result;
  final VoidCallback? onResolve;

  @override
  Widget build(BuildContext context) {
    final r = result;
    final f = r.failure;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (f != null) ...[
          Text(
            'Failed: ${f.reason.name}',
            style: const TextStyle(color: Colors.red),
          ),
          Text(f.message),
          if (f.details['fix'] != null) SelectableText('${f.details['fix']}'),
          if (f.resolution != LocationResolution.none && onResolve != null)
            TextButton(
              onPressed: onResolve,
              child: Text('Resolve: ${f.resolution.name}'),
            ),
        ] else ...[
          Text('${r.position}'),
          if (r.address != null) Text(r.address!.formatted),
          if (r.addressFailure != null)
            Text('No address: ${r.addressFailure!.reason.name}'),
          Text(
            'source ${r.source!.name} · age ${age(r.age)}'
            '${r.isApproximate ? ' · approximate' : ''}'
            '${r.isMocked ? ' · MOCKED' : ''}',
          ),
        ],
        const SizedBox(height: 4),
        Text(
          'steps: ${r.steps.map((s) => s.name).join(' → ')}',
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    );
  }
}

class GetSection extends StatefulWidget {
  const GetSection({super.key, required this.profile, required this.locale});
  final LocationProfile profile;
  final Locale locale;
  @override
  State<GetSection> createState() => _GetSectionState();
}

class _GetSectionState extends State<GetSection> {
  LocationResult? result;
  bool busy = false;

  Future<void> _get() async {
    setState(() => busy = true);
    final r = await location.get(
      profile: widget.profile,
      withAddress: true,
      locale: widget.locale,
      context: context,
    );
    if (!mounted) return;
    setState(() {
      result = r;
      busy = false;
    });
  }

  @override
  Widget build(BuildContext context) => _Section('get(withAddress: true)', [
    FilledButton.icon(
      onPressed: busy ? null : _get,
      icon: const Icon(Icons.my_location),
      label: Text(busy ? 'Locating…' : 'Get my location'),
    ),
    if (result != null) ...[
      const SizedBox(height: 8),
      ResultView(
        result!,
        onResolve: () async {
          await location.resolve(result!.failure!, context: context);
          await _get();
        },
      ),
    ],
  ]);
}

class LastKnownSection extends StatefulWidget {
  const LastKnownSection({super.key});
  @override
  State<LastKnownSection> createState() => _LastKnownSectionState();
}

class _LastKnownSectionState extends State<LastKnownSection> {
  LocationResult? result;

  Future<void> _load() async {
    final r = await location.lastKnownPosition();
    if (mounted) setState(() => result = r);
  }

  @override
  Widget build(BuildContext context) => _Section('Last known location', [
    Wrap(
      spacing: 8,
      children: [
        OutlinedButton(onPressed: _load, child: const Text('Read')),
        OutlinedButton(
          // Call this on logout and account switch.
          onPressed: () async {
            await location.clearStoredLocation();
            await _load();
          },
          child: const Text('Clear stored location'),
        ),
      ],
    ),
    if (result != null) ResultView(result!),
  ]);
}

class GeocodingSection extends StatefulWidget {
  const GeocodingSection({super.key, required this.locale});
  final Locale locale;
  @override
  State<GeocodingSection> createState() => _GeocodingSectionState();
}

class _GeocodingSectionState extends State<GeocodingSection> {
  final coords = TextEditingController(text: '31.5204, 74.3587');
  final query = TextEditingController(text: 'Mall Road, Lahore');
  String output = '';

  /// A newer lookup from this section supersedes an older one still
  /// running.
  final consumer = Object();

  Future<void> _reverse() async {
    final parts = coords.text
        .split(',')
        .map((s) => double.tryParse(s.trim()))
        .toList();
    final r = await location.addressFrom(
      null,
      lat: parts.isNotEmpty ? parts[0] : null,
      lng: parts.length > 1 ? parts[1] : null,
      locale: widget.locale,
      consumer: consumer,
    );
    if (!mounted) return;
    setState(
      () => output = r.isSuccess
          ? '${r.address!.formatted}${r.fromCache ? '  (cached)' : ''}'
          : 'Failed: ${r.failure!.reason.name}',
    );
  }

  Future<void> _forward() async {
    final r = await location.positionsFrom(
      query.text,
      locale: widget.locale,
      consumer: consumer,
    );
    if (!mounted) return;
    setState(
      () => output = r.isSuccess
          ? r.places
                .map(
                  (p) =>
                      '${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}'
                      '${p.address != null ? '  ${p.address!.formatted}' : ''}',
                )
                .join('\n')
          : 'Failed: ${r.failure!.reason.name}',
    );
  }

  @override
  Widget build(BuildContext context) =>
      _Section('Geocoding (${widget.locale.toLanguageTag()})', [
        Row(
          children: [
            Expanded(
              child: TextField(
                controller: coords,
                decoration: const InputDecoration(labelText: 'lat, lng'),
              ),
            ),
            TextButton(onPressed: _reverse, child: const Text('Address')),
          ],
        ),
        Row(
          children: [
            Expanded(
              child: TextField(
                controller: query,
                decoration: const InputDecoration(labelText: 'Address'),
              ),
            ),
            TextButton(onPressed: _forward, child: const Text('Places')),
          ],
        ),
        if (output.isNotEmpty)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: SelectableText(output),
          ),
      ]);
}

class StreamSection extends StatefulWidget {
  const StreamSection({super.key, required this.profile});
  final LocationProfile profile;
  @override
  State<StreamSection> createState() => _StreamSectionState();
}

class _StreamSectionState extends State<StreamSection> {
  StreamSubscription<Position>? sub;
  double distance = 10;
  bool background = false;
  Position? last;
  int count = 0;

  Future<void> _start() async {
    if (background) {
      // Android 13+ hides the tracking notification without this; the app
      // asks, not baaba_location. Tracking runs either way.
      await BaabaPermissions.instance.request(
        AppPermission.notification,
        context: context,
      );
      if (!mounted) return;
    }
    sub = location
        .positionStream(
          accuracy: widget.profile.accuracy,
          interval: widget.profile.interval,
          distanceFilter: distance,
          filters: widget.profile.filters,
          background: background
              ? const BackgroundLocation(
                  notificationTitle: 'baaba_location example',
                  notificationText: 'Tracking until you press Stop.',
                )
              : null,
        )
        .listen(
          (p) => setState(() {
            last = p;
            count++;
          }),
        );
    setState(() {});
  }

  Future<void> _stop() async {
    await sub?.cancel();
    setState(() => sub = null);
  }

  @override
  void dispose() {
    sub?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      _Section('positionStream (${widget.profile.name})', [
        Text('Distance filter: ${distance.round()} m'),
        Slider(
          value: distance,
          max: 200,
          divisions: 20,
          onChanged: sub == null ? (v) => setState(() => distance = v) : null,
        ),
        SwitchListTile(
          contentPadding: EdgeInsets.zero,
          title: const Text('Keep running in the background'),
          subtitle: const Text(
            'BackgroundLocation: Android notification, iOS indicator',
          ),
          value: background,
          onChanged: sub == null ? (v) => setState(() => background = v) : null,
        ),
        FilledButton.tonal(
          onPressed: sub == null ? _start : _stop,
          child: Text(sub == null ? 'Start' : 'Stop'),
        ),
        if (sub != null) Text('$count fixes · last: ${last ?? '—'}'),
      ]);
}

class StateSection extends StatefulWidget {
  const StateSection({super.key});
  @override
  State<StateSection> createState() => _StateSectionState();
}

class _StateSectionState extends State<StateSection> {
  LocationDiagnostics? report;

  @override
  Widget build(BuildContext context) => _Section('State and diagnose()', [
    StreamBuilder<LocationStatus>(
      stream: location.statusStream,
      builder: (context, s) => Text(
        s.hasData
            ? '${s.data}'
            : 'Waiting for a change (toggle location or permission)…',
      ),
    ),
    FutureBuilder<LocationAccess>(
      future: location.checkPermission(),
      builder: (context, s) => Text('Permission now: ${s.data ?? '…'}'),
    ),
    const SizedBox(height: 8),
    OutlinedButton(
      onPressed: () async {
        final r = await location.diagnose();
        if (mounted) setState(() => report = r);
      },
      child: const Text('Run diagnose()'),
    ),
    // In an app with baaba_app_config, the same rows go into its debug panel:
    //   DebugSection.info('Location', () => report.toMap())
    if (report != null)
      for (final e in report!.toMap().entries)
        Text(
          '${e.key}: ${e.value}',
          style: Theme.of(context).textTheme.bodySmall,
        ),
    if (report != null && report!.issues.isNotEmpty)
      SelectableText(report!.toChecklist()),
  ]);
}

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

  @override
  Widget build(BuildContext context) => _Section('Observer events', [
    ValueListenableBuilder<List<String>>(
      valueListenable: log.lines,
      builder: (context, lines, _) => lines.isEmpty
          ? const Text('Nothing yet.')
          : Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                for (final l in lines)
                  Text(l, style: Theme.of(context).textTheme.bodySmall),
              ],
            ),
    ),
  ]);
}
0
likes
150
points
143
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Foreground location for Flutter that never throws: one call runs permission, services, fix, fallback and address lookup, with filters, a setup doctor and a test double.

Topics

#location #geolocation #geocoding #gps

License

MIT (license)

Dependencies

baaba_permission_handler, device_info_plus, flutter, geocoding, geolocator, geolocator_android, path_provider

More

Packages that depend on baaba_location

Packages that implement baaba_location