baaba_location 0.1.0
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),
],
),
),
]);
}