flutter_location_plus 1.0.0
flutter_location_plus: ^1.0.0 copied to clipboard
Native Android location plugin — GPS, Network & Passive providers. No Google Play Services. Replaces nativecode_location.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_location_plus/flutter_location_plus.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'flutter_location_plus',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const _RootScreen(),
);
}
}
class _RootScreen extends StatelessWidget {
const _RootScreen();
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
appBar: AppBar(
title: const Text('Location Tracker'),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
bottom: TabBar(
labelColor: Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor:
Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.6),
indicatorColor: Theme.of(context).colorScheme.onPrimary,
tabs: const [
Tab(icon: Icon(Icons.gps_fixed), text: 'One-Shot'),
Tab(icon: Icon(Icons.radio_button_checked), text: 'Live'),
],
),
),
body: const TabBarView(
children: [OneShotTab(), LiveTab()],
),
),
);
}
}
// ════════════════════════════════════════════════════════════════════════════
// ONE-SHOT TAB
// ════════════════════════════════════════════════════════════════════════════
enum _Status { idle, loading, success, error }
class _ProviderResult {
final _Status status;
final double? lat;
final double? lng;
final String? provider;
final String? errorMsg;
final DateTime? updatedAt;
const _ProviderResult({
this.status = _Status.idle,
this.lat,
this.lng,
this.provider,
this.errorMsg,
this.updatedAt,
});
}
class OneShotTab extends StatefulWidget {
const OneShotTab({super.key});
@override
State<OneShotTab> createState() => _OneShotTabState();
}
class _OneShotTabState extends State<OneShotTab> {
final _plugin = FlutterLocationPlus();
late final Map<String, _ProviderResult> _results = {
'gps': const _ProviderResult(),
'network': const _ProviderResult(),
'passive': const _ProviderResult(),
'best': const _ProviderResult(),
};
Future<void> _fetch(String key) async {
setState(() => _results[key] = const _ProviderResult(status: _Status.loading));
try {
final raw = await switch (key) {
'gps' => _plugin.getGpsLocation(),
'network' => _plugin.getNetworkLocation(),
'passive' => _plugin.getPassiveLocation(),
_ => _plugin.getBestLocation(),
};
final parts = raw?.split(',');
if (parts != null && parts.length == 3 && parts[0] != 'null') {
setState(() => _results[key] = _ProviderResult(
status: _Status.success,
lat: double.parse(parts[0]),
lng: double.parse(parts[1]),
provider: parts[2],
updatedAt: DateTime.now(),
));
} else {
setState(() => _results[key] = _ProviderResult(
status: _Status.error,
errorMsg: _friendlyError(parts?.length == 3 ? parts![2] : 'unavailable'),
));
}
} catch (e) {
setState(() => _results[key] =
_ProviderResult(status: _Status.error, errorMsg: e.toString()));
}
}
String _friendlyError(String code) => switch (code) {
'disabled' => 'Provider is disabled on this device',
'no_fix' => 'No fix yet — try again in a moment',
'error' => 'Permission or system error',
_ => 'Location unavailable',
};
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_ProviderCard(
title: 'GPS',
subtitle: 'Satellite — most accurate outdoors',
icon: Icons.satellite_alt,
color: Colors.green,
result: _results['gps']!,
onFetch: () => _fetch('gps'),
),
const SizedBox(height: 12),
_ProviderCard(
title: 'Network',
subtitle: 'Cell towers & Wi-Fi — fast indoors',
icon: Icons.cell_tower,
color: Colors.blue,
result: _results['network']!,
onFetch: () => _fetch('network'),
),
const SizedBox(height: 12),
_ProviderCard(
title: 'Passive',
subtitle: 'Reuses fixes from other apps',
icon: Icons.location_searching,
color: Colors.orange,
result: _results['passive']!,
onFetch: () => _fetch('passive'),
),
const SizedBox(height: 20),
_BestCard(result: _results['best']!, onFetch: () => _fetch('best'), cs: cs),
],
);
}
}
// ════════════════════════════════════════════════════════════════════════════
// LIVE TAB
// ════════════════════════════════════════════════════════════════════════════
class LiveTab extends StatefulWidget {
const LiveTab({super.key});
@override
State<LiveTab> createState() => _LiveTabState();
}
class _LiveTabState extends State<LiveTab> {
final _plugin = FlutterLocationPlus();
StreamSubscription<LocationData>? _sub;
String _selectedProvider = 'best';
int _intervalMs = 2000;
double _distanceM = 0;
bool get _isTracking => _sub != null;
LocationData? _latest;
final List<LocationData> _history = [];
String? _error;
void _start() {
_error = null;
_history.clear();
_latest = null;
_sub = _plugin
.liveLocation(
provider: _selectedProvider,
intervalMs: _intervalMs,
distanceMeters: _distanceM,
)
.listen(
(data) => setState(() {
_latest = data;
_history.insert(0, data);
if (_history.length > 50) _history.removeLast();
}),
onError: (e) => setState(() => _error = e.toString()),
);
setState(() {});
}
void _stop() {
_sub?.cancel();
_sub = null;
_plugin.stopLiveLocation();
setState(() {});
}
@override
void dispose() {
_sub?.cancel();
_plugin.stopLiveLocation();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Column(
children: [
_LiveControlPanel(
selectedProvider: _selectedProvider,
intervalMs: _intervalMs,
distanceM: _distanceM,
isTracking: _isTracking,
onProviderChanged: _isTracking ? null : (v) => setState(() => _selectedProvider = v!),
onIntervalChanged: _isTracking ? null : (v) => setState(() => _intervalMs = v),
onDistanceChanged: _isTracking ? null : (v) => setState(() => _distanceM = v),
onStart: _start,
onStop: _stop,
),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(children: [
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 16),
const SizedBox(width: 6),
Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red, fontSize: 13))),
]),
),
if (_latest != null) _LiveCurrentCard(data: _latest!, cs: cs),
Expanded(child: _LiveHistoryList(history: _history)),
],
);
}
}
// ── Live Control Panel ────────────────────────────────────────────────────────
class _LiveControlPanel extends StatelessWidget {
final String selectedProvider;
final int intervalMs;
final double distanceM;
final bool isTracking;
final ValueChanged<String?>? onProviderChanged;
final ValueChanged<int>? onIntervalChanged;
final ValueChanged<double>? onDistanceChanged;
final VoidCallback onStart;
final VoidCallback onStop;
const _LiveControlPanel({
required this.selectedProvider,
required this.intervalMs,
required this.distanceM,
required this.isTracking,
required this.onProviderChanged,
required this.onIntervalChanged,
required this.onDistanceChanged,
required this.onStart,
required this.onStop,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.all(16),
elevation: 0,
color: cs.surfaceContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Provider selector
Row(
children: [
const Text('Provider', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: selectedProvider,
isDense: true,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
items: const [
DropdownMenuItem(value: 'best', child: Text('Best (auto)')),
DropdownMenuItem(value: 'gps', child: Text('GPS only')),
DropdownMenuItem(value: 'network', child: Text('Network only')),
DropdownMenuItem(value: 'passive', child: Text('Passive only')),
],
onChanged: onProviderChanged,
),
),
],
),
const SizedBox(height: 12),
// Interval slider
Row(
children: [
const SizedBox(width: 4),
const Icon(Icons.timer_outlined, size: 18, color: Colors.grey),
const SizedBox(width: 6),
Text('Interval: ${intervalMs}ms',
style: const TextStyle(fontSize: 13, color: Colors.grey)),
Expanded(
child: Slider(
value: intervalMs.toDouble(),
min: 500,
max: 10000,
divisions: 19,
onChanged: onIntervalChanged != null
? (v) => onIntervalChanged!(v.round())
: null,
),
),
],
),
// Distance slider
Row(
children: [
const SizedBox(width: 4),
const Icon(Icons.straighten, size: 18, color: Colors.grey),
const SizedBox(width: 6),
Text('Min dist: ${distanceM.round()}m',
style: const TextStyle(fontSize: 13, color: Colors.grey)),
Expanded(
child: Slider(
value: distanceM,
min: 0,
max: 100,
divisions: 20,
onChanged: onDistanceChanged != null
? (v) => onDistanceChanged!(v)
: null,
),
),
],
),
const SizedBox(height: 4),
SizedBox(
width: double.infinity,
child: isTracking
? FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: onStop,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Stop Tracking'),
)
: FilledButton.icon(
onPressed: onStart,
icon: const Icon(Icons.radio_button_checked),
label: const Text('Start Live Tracking'),
),
),
],
),
),
);
}
}
// ── Live Current Card ─────────────────────────────────────────────────────────
class _LiveCurrentCard extends StatelessWidget {
final LocationData data;
final ColorScheme cs;
const _LiveCurrentCard({required this.data, required this.cs});
static const _providerColor = {
'gps': Colors.green,
'network': Colors.blue,
'passive': Colors.orange,
};
@override
Widget build(BuildContext context) {
final color = _providerColor[data.provider] ?? Colors.indigo;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
elevation: 0,
color: cs.primaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Icon(Icons.my_location, color: cs.primary, size: 18),
const SizedBox(width: 6),
Text('Live Location',
style: TextStyle(
fontWeight: FontWeight.bold, color: cs.primary)),
]),
Chip(
label: Text(data.provider.toUpperCase(),
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 11)),
backgroundColor: color.withValues(alpha: 0.1),
side: BorderSide(color: color.withValues(alpha: 0.3)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _StatTile(label: 'Latitude', value: data.lat.toStringAsFixed(6))),
Expanded(child: _StatTile(label: 'Longitude', value: data.lng.toStringAsFixed(6))),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatTile(
label: 'Accuracy',
value: '±${data.accuracy.toStringAsFixed(1)}m')),
Expanded(
child: _StatTile(
label: 'Speed',
value: '${(data.speed * 3.6).toStringAsFixed(1)} km/h')),
],
),
],
),
),
),
);
}
}
class _StatTile extends StatelessWidget {
final String label;
final String value;
const _StatTile({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(fontSize: 11, color: Colors.grey)),
Text(value,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
],
);
}
}
// ── Live History List ─────────────────────────────────────────────────────────
class _LiveHistoryList extends StatelessWidget {
final List<LocationData> history;
const _LiveHistoryList({required this.history});
@override
Widget build(BuildContext context) {
if (history.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history, size: 48, color: Colors.grey.shade300),
const SizedBox(height: 8),
Text('No updates yet',
style: TextStyle(color: Colors.grey.shade400)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: history.length,
itemBuilder: (context, i) {
final d = history[i];
final isLatest = i == 0;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: ListTile(
dense: true,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
tileColor: isLatest
? Theme.of(context).colorScheme.surfaceContainerHigh
: Theme.of(context).colorScheme.surfaceContainer,
leading: CircleAvatar(
radius: 14,
backgroundColor: isLatest
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
child: Text(
'${history.length - i}',
style: TextStyle(
fontSize: 10,
color: isLatest ? Colors.white : Colors.grey.shade700),
),
),
title: Text(
'${d.lat.toStringAsFixed(5)}, ${d.lng.toStringAsFixed(5)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
subtitle: Text(
'${d.provider} · ±${d.accuracy.toStringAsFixed(1)}m · ${(d.speed * 3.6).toStringAsFixed(1)} km/h',
style: const TextStyle(fontSize: 11),
),
trailing: Text(
_formatTime(d.time),
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
),
);
},
);
}
String _formatTime(DateTime t) {
final now = DateTime.now();
final diff = now.difference(t).inSeconds;
if (diff < 5) return 'now';
if (diff < 60) return '${diff}s ago';
return '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
}
}
// ════════════════════════════════════════════════════════════════════════════
// ONE-SHOT SHARED WIDGETS
// ════════════════════════════════════════════════════════════════════════════
class _ProviderCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final Color color;
final _ProviderResult result;
final VoidCallback onFetch;
const _ProviderCard({
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.result,
required this.onFetch,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: cs.surfaceContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
Text(subtitle,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey)),
],
),
),
_FetchIconButton(
loading: result.status == _Status.loading,
color: color,
onTap: onFetch),
],
),
const SizedBox(height: 12),
_ResultBody(result: result, color: color),
],
),
),
);
}
}
class _BestCard extends StatelessWidget {
final _ProviderResult result;
final VoidCallback onFetch;
final ColorScheme cs;
const _BestCard(
{required this.result, required this.onFetch, required this.cs});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
color: cs.primaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: cs.primary.withValues(alpha: 0.15),
child: Icon(Icons.auto_awesome, color: cs.primary, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Best Available',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
Text('GPS → Network → Passive fallback',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey.shade600)),
],
),
),
_FetchIconButton(
loading: result.status == _Status.loading,
color: cs.primary,
onTap: onFetch),
],
),
const SizedBox(height: 12),
_ResultBody(result: result, color: cs.primary),
],
),
),
);
}
}
class _ResultBody extends StatelessWidget {
final _ProviderResult result;
final Color color;
const _ResultBody({required this.result, required this.color});
@override
Widget build(BuildContext context) {
return switch (result.status) {
_Status.idle => const Text('Tap the button to fetch',
style: TextStyle(color: Colors.grey, fontSize: 13)),
_Status.loading => const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: CircularProgressIndicator(strokeWidth: 2),
)),
_Status.error => Row(children: [
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(result.errorMsg ?? 'Unknown error',
style: const TextStyle(color: Colors.red, fontSize: 13))),
]),
_Status.success => Column(children: [
_KV(label: 'Latitude', value: result.lat!.toStringAsFixed(6)),
const SizedBox(height: 4),
_KV(label: 'Longitude', value: result.lng!.toStringAsFixed(6)),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Provider',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey)),
Chip(
label: Text(result.provider!.toUpperCase(),
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 11)),
backgroundColor: color.withValues(alpha: 0.1),
side: BorderSide(color: color.withValues(alpha: 0.3)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
if (result.updatedAt != null)
Align(
alignment: Alignment.centerRight,
child: Text(
'Updated ${_ago(result.updatedAt!)}',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
),
]),
};
}
String _ago(DateTime t) {
final s = DateTime.now().difference(t).inSeconds;
if (s < 5) return 'just now';
if (s < 60) return '${s}s ago';
return '${s ~/ 60}m ago';
}
}
class _KV extends StatelessWidget {
final String label;
final String value;
const _KV({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey)),
Text(value,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
],
);
}
}
class _FetchIconButton extends StatelessWidget {
final bool loading;
final Color color;
final VoidCallback onTap;
const _FetchIconButton(
{required this.loading, required this.color, required this.onTap});
@override
Widget build(BuildContext context) {
return IconButton.filled(
style: IconButton.styleFrom(backgroundColor: color.withValues(alpha: 0.15)),
onPressed: loading ? null : onTap,
icon: loading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: color))
: Icon(Icons.refresh, color: color),
);
}
}