locus 1.1.0
locus: ^1.1.0 copied to clipboard
Background geolocation SDK for Flutter. Native tracking, geofencing, activity recognition, and sync.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart' hide Config;
import 'package:locus/locus.dart';
void main() => runApp(const MotionRecognitionApp());
enum TrackingPreset {
lowPower,
balanced,
tracking,
trail,
}
class MotionRecognitionApp extends StatefulWidget {
const MotionRecognitionApp({super.key});
@override
State<MotionRecognitionApp> createState() => _MotionRecognitionAppState();
}
class _MotionRecognitionAppState extends State<MotionRecognitionApp> {
static const int _maxEventEntries = 250;
StreamSubscription<Location>? _locationSubscription;
StreamSubscription<Location>? _motionSubscription;
StreamSubscription<Activity>? _activitySubscription;
StreamSubscription<LocationAnomaly>? _anomalySubscription;
StreamSubscription<TripEvent>? _tripSubscription;
StreamSubscription<GeofenceWorkflowEvent>? _workflowSubscription;
StreamSubscription<ProviderChangeEvent>? _providerSubscription;
StreamSubscription<GeofenceEvent>? _geofenceSubscription;
StreamSubscription<dynamic>? _geofencesChangeSubscription;
StreamSubscription<Location>? _heartbeatSubscription;
StreamSubscription<Location>? _scheduleSubscription;
StreamSubscription<ConnectivityChangeEvent>? _connectivitySubscription;
StreamSubscription<bool>? _powerSaveSubscription;
StreamSubscription<bool>? _enabledSubscription;
StreamSubscription<HttpEvent>? _httpSubscription;
StreamSubscription<String>? _notificationActionSubscription;
final List<String> _events = [];
final Map<String, int> _eventCounts = {};
Location? _latestLocation;
Activity? _lastActivity;
ProviderChangeEvent? _lastProvider;
ConnectivityChangeEvent? _lastConnectivity;
GeofenceEvent? _lastGeofence;
HttpEvent? _lastHttp;
GeolocationState? _lastState;
String? _lastNotificationAction;
List<LogEntry>? _lastLog;
TripSummary? _lastTripSummary;
PowerState? _powerState;
BatteryStats? _batteryStats;
bool _spoofDetectionEnabled = false;
bool _significantChangesEnabled = false;
String? _benchmarkStatus;
bool _isRunning = false;
bool _isReady = false;
bool _scheduleEnabled = false;
TrackingPreset _selectedPreset = TrackingPreset.tracking;
TrackingProfile? _currentProfile;
List<Location> _storedLocations = [];
@override
void initState() {
super.initState();
_configure();
}
Future<void> _configure() async {
final isGranted = await Locus.requestPermission();
if (!isGranted) {
_showSnackbar('Location permission is required to use this app.');
return;
}
final config = _buildConfig(_selectedPreset);
await Locus.ready(config);
await _configureTrackingProfiles();
_configureWorkflow();
_setupListeners();
await _refreshState();
setState(() {
_isReady = true;
});
}
Config _buildConfig(TrackingPreset preset) {
final presetConfig = switch (preset) {
TrackingPreset.lowPower => ConfigPresets.lowPower,
TrackingPreset.balanced => ConfigPresets.balanced,
TrackingPreset.tracking => ConfigPresets.tracking,
TrackingPreset.trail => ConfigPresets.trail,
};
return presetConfig.copyWith(
stationaryRadius: 25,
motionTriggerDelay: 15000,
activityRecognitionInterval: 10000,
startOnBoot: true,
stopOnTerminate: false,
enableHeadless: true,
disableAutoSyncOnCellular: true,
maxBatchSize: 20,
autoSyncThreshold: 10,
maxRetry: 3,
retryDelay: 5000,
retryDelayMultiplier: 2.0,
maxRetryDelay: 60000,
persistMode: PersistMode.location,
maxDaysToPersist: 7,
maxRecordsToPersist: 200,
maxMonitoredGeofences: 20,
url: 'https://example.com/locations',
logLevel: LogLevel.info,
logMaxDays: 7,
schedule: const ['08:00-12:00', '13:00-18:00'],
notification: const NotificationConfig(
title: 'Locus',
text: 'Tracking location in background',
actions: ['PAUSE', 'STOP'],
),
);
}
String _presetLabel(TrackingPreset preset) {
return switch (preset) {
TrackingPreset.lowPower => 'Low Power',
TrackingPreset.balanced => 'Balanced',
TrackingPreset.tracking => 'Tracking',
TrackingPreset.trail => 'Trail',
};
}
Future<void> _applyPreset(TrackingPreset preset) async {
final config = _buildConfig(preset);
await Locus.setConfig(config);
setState(() {
_selectedPreset = preset;
});
_recordEvent('preset', 'preset ${_presetLabel(preset)} applied');
}
Future<void> _configureTrackingProfiles() async {
await Locus.setTrackingProfiles(
{
TrackingProfile.offDuty: ConfigPresets.lowPower,
TrackingProfile.standby: ConfigPresets.balanced,
TrackingProfile.enRoute: ConfigPresets.tracking,
TrackingProfile.arrived: ConfigPresets.trail,
},
initialProfile: TrackingProfile.standby,
enableAutomation: false,
);
setState(() {
_currentProfile = Locus.currentTrackingProfile;
});
}
Future<void> _applyProfile(TrackingProfile profile) async {
await Locus.setTrackingProfile(profile);
setState(() {
_currentProfile = Locus.currentTrackingProfile;
});
_recordEvent('profile', 'profile ${profile.name} applied');
}
void _configureWorkflow() {
Locus.registerGeofenceWorkflows(const [
GeofenceWorkflow(
id: 'pickup_dropoff',
steps: [
GeofenceWorkflowStep(
id: 'pickup',
geofenceIdentifier: 'demo_geofence',
action: GeofenceAction.enter,
),
GeofenceWorkflowStep(
id: 'dropoff',
geofenceIdentifier: 'demo_geofence',
action: GeofenceAction.exit,
),
],
),
]);
}
@override
void dispose() {
_locationSubscription?.cancel();
_motionSubscription?.cancel();
_activitySubscription?.cancel();
_anomalySubscription?.cancel();
_tripSubscription?.cancel();
_workflowSubscription?.cancel();
_providerSubscription?.cancel();
_geofenceSubscription?.cancel();
_geofencesChangeSubscription?.cancel();
_heartbeatSubscription?.cancel();
_scheduleSubscription?.cancel();
_connectivitySubscription?.cancel();
_powerSaveSubscription?.cancel();
_enabledSubscription?.cancel();
_httpSubscription?.cancel();
_notificationActionSubscription?.cancel();
super.dispose();
}
void _setupListeners() {
_locationSubscription = Locus.onLocation((location) {
_recordEvent(
'location',
_formatLocationEvent(location, 'location'),
updateState: () => _latestLocation = location,
);
}, onError: _onError);
_motionSubscription = Locus.onMotionChange((location) {
_recordEvent(
'motionchange',
_formatLocationEvent(location, 'motionchange'),
updateState: () => _latestLocation = location,
);
}, onError: _onError);
_activitySubscription = Locus.onActivityChange((activity) {
_recordEvent(
'activitychange',
'activity ${activity.type.name} (${activity.confidence}%)',
updateState: () => _lastActivity = activity,
);
}, onError: _onError);
_anomalySubscription = Locus.onLocationAnomaly(
(anomaly) {
_recordEvent(
'anomaly',
'anomaly ${anomaly.speedKph.toStringAsFixed(1)} kph over '
'${anomaly.distanceMeters.toStringAsFixed(0)} m',
);
},
config: const LocationAnomalyConfig(
maxSpeedKph: 200,
minDistanceMeters: 500,
),
onError: _onError,
);
_tripSubscription = Locus.onTripEvent((event) {
_recordEvent('trip', 'trip ${event.type.name}');
if (event.summary != null) {
setState(() {
_lastTripSummary = event.summary;
});
}
}, onError: _onError);
_workflowSubscription = Locus.onWorkflowEvent((event) {
_recordEvent(
'workflow',
'workflow ${event.workflowId} ${event.status.name}',
);
}, onError: _onError);
_providerSubscription = Locus.onProviderChange((event) {
_recordEvent(
'providerchange',
'provider enabled=${event.enabled} auth=${event.authorizationStatus.name}',
updateState: () => _lastProvider = event,
);
}, onError: _onError);
_geofenceSubscription = Locus.onGeofence((event) {
_recordEvent(
'geofence',
'geofence ${event.geofence.identifier} ${event.action.name}',
updateState: () => _lastGeofence = event,
);
}, onError: _onError);
_geofencesChangeSubscription = Locus.onGeofencesChange((event) {
_recordEvent('geofenceschange', 'geofences change: $event');
}, onError: _onError);
_heartbeatSubscription = Locus.onHeartbeat((location) {
_recordEvent('heartbeat', _formatLocationEvent(location, 'heartbeat'));
}, onError: _onError);
_scheduleSubscription = Locus.onSchedule((location) {
_recordEvent('schedule', _formatLocationEvent(location, 'schedule'));
}, onError: _onError);
_connectivitySubscription = Locus.onConnectivityChange((event) {
_recordEvent(
'connectivity',
'connectivity ${event.networkType ?? 'unknown'} connected=${event.connected}',
updateState: () => _lastConnectivity = event,
);
}, onError: _onError);
_powerSaveSubscription = Locus.onPowerSaveChange((enabled) {
_recordEvent('powersave', 'powersave enabled=$enabled');
}, onError: _onError);
_enabledSubscription = Locus.onEnabledChange((enabled) {
_recordEvent(
'enabledchange',
'enabled=$enabled',
updateState: () => _isRunning = enabled,
);
}, onError: _onError);
_httpSubscription = Locus.onHttp((event) {
_recordEvent(
'http',
'http status=${event.status} ok=${event.ok}',
updateState: () => _lastHttp = event,
);
}, onError: _onError);
_notificationActionSubscription = Locus.onNotificationAction((action) {
_recordEvent(
'notification',
'notification action=$action',
updateState: () => _lastNotificationAction = action,
);
}, onError: _onError);
}
Future<void> _refreshState() async {
final state = await Locus.getState();
setState(() {
_lastState = state;
_isRunning = state.enabled;
_scheduleEnabled = state.schedulerEnabled ?? _scheduleEnabled;
});
}
Future<void> _startOrStopTracking() async {
if (!_isReady) {
_showSnackbar('Call ready() first.');
return;
}
if (_isRunning) {
await Locus.stop();
} else {
await Locus.start();
}
await _refreshState();
}
Future<void> _getCurrentPosition() async {
try {
final location = await Locus.getCurrentPosition();
_showSnackbar(
'Position: ${location.coords.latitude.toStringAsFixed(5)}, ${location.coords.longitude.toStringAsFixed(5)}',
);
_recordEvent(
'currentposition',
_formatLocationEvent(location, 'getCurrentPosition'),
updateState: () => _latestLocation = location,
);
} catch (e) {
_onError(e);
}
}
Future<void> _toggleSchedule() async {
if (_scheduleEnabled) {
await Locus.stopSchedule();
} else {
await Locus.startSchedule();
}
setState(() {
_scheduleEnabled = !_scheduleEnabled;
});
}
Future<void> _loadStoredLocations() async {
final locations = await Locus.getLocations(limit: 50);
setState(() {
_storedLocations = locations;
});
}
Future<void> _clearStoredLocations() async {
await Locus.destroyLocations();
setState(() {
_storedLocations = [];
});
}
Future<void> _startTrip() async {
await Locus.startTrip(const TripConfig(
startOnMoving: true,
updateIntervalSeconds: 30,
route: [
RoutePoint(latitude: 37.4219983, longitude: -122.084),
RoutePoint(latitude: 37.4279613, longitude: -122.0857497),
],
routeDeviationThresholdMeters: 150,
));
_recordEvent('trip', 'trip start requested');
}
Future<void> _stopTrip() async {
final summary = Locus.stopTrip();
setState(() {
_lastTripSummary = summary;
});
_recordEvent('trip', 'trip stop requested');
}
Future<void> _addDemoGeofence() async {
await Locus.addGeofence(const Geofence(
identifier: 'demo_geofence',
radius: 100,
latitude: 37.4219983,
longitude: -122.084,
notifyOnEntry: true,
notifyOnExit: true,
notifyOnDwell: true,
loiteringDelay: 300000,
extras: {'source': 'example'},
));
_showSnackbar('Geofence added');
}
Future<void> _removeAllGeofences() async {
await Locus.removeGeofences();
_showSnackbar('Geofences cleared');
}
Future<void> _loadLog() async {
final log = await Locus.getLog();
setState(() {
_lastLog = log;
});
}
Future<void> _emailLog() async {
await Locus.emailLog('logs@example.com');
_showSnackbar('Requested log email');
}
void _clearEvents() {
setState(() {
_events.clear();
_eventCounts.clear();
});
}
void _recordEvent(
String type,
String message, {
VoidCallback? updateState,
}) {
final timestamp = _formatTimestamp(DateTime.now());
setState(() {
updateState?.call();
_events.insert(0, '[$timestamp] $message');
_eventCounts[type] = (_eventCounts[type] ?? 0) + 1;
if (_events.length > _maxEventEntries) {
_events.removeLast();
}
});
}
void _onError(Object error) {
if (mounted) {
_showSnackbar('Error: $error');
}
}
void _showSnackbar(String message) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF2F5D50),
brightness: Brightness.light,
);
return MaterialApp(
theme: ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF4F1EC),
textTheme: GoogleFonts.spaceGroteskTextTheme(),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
home: DefaultTabController(
length: 5,
child: Scaffold(
appBar: AppBar(
title: const Text('Locus'),
actions: [
IconButton(
onPressed: _refreshState,
icon: const Icon(Icons.sync),
),
IconButton(
onPressed: _clearEvents,
icon: const Icon(Icons.delete_outline),
),
],
bottom: const TabBar(
tabs: [
Tab(text: 'Overview', icon: Icon(Icons.route)),
Tab(text: 'Events', icon: Icon(Icons.timeline)),
Tab(text: 'Storage', icon: Icon(Icons.storage)),
Tab(text: 'Diagnostics', icon: Icon(Icons.tune)),
Tab(text: 'Advanced', icon: Icon(Icons.science)),
],
),
),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFF4F1EC),
Color(0xFFE7EFEA),
],
),
),
child: TabBarView(
children: [
_buildOverviewTab(),
_buildEventsTab(),
_buildStorageTab(),
_buildDiagnosticsTab(),
_buildAdvancedTab(),
],
),
),
),
),
);
}
Widget _buildOverviewTab() {
return SingleChildScrollView(
child: Column(
children: [
_buildStatusPanel(),
_buildControlPanel(),
_buildQuickStats(),
],
),
);
}
Widget _buildEventsTab() {
return Column(
children: [
_buildEventSummary(),
Expanded(child: _buildEventList()),
],
);
}
Widget _buildStorageTab() {
return SingleChildScrollView(
child: Column(
children: [
_buildStoragePanel(),
_buildStoredList(),
],
),
);
}
Widget _buildDiagnosticsTab() {
return SingleChildScrollView(
child: Column(
children: [
_buildDiagnosticsPanel(),
_buildLogPanel(),
],
),
);
}
Widget _buildStatusPanel() {
final location = _latestLocation;
final provider = _lastProvider;
final connectivity = _lastConnectivity;
final state = _lastState;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildStatusChip(
label: _isReady ? 'Ready' : 'Not Ready',
icon: Icons.flash_on,
active: _isReady,
),
_buildStatusChip(
label: _isRunning ? 'Tracking' : 'Stopped',
icon: _isRunning ? Icons.play_arrow : Icons.pause,
active: _isRunning,
),
_buildStatusChip(
label: state?.isMoving == true ? 'Moving' : 'Stationary',
icon: Icons.directions_walk,
active: state?.isMoving == true,
),
_buildStatusChip(
label: _scheduleEnabled ? 'Schedule On' : 'Schedule Off',
icon: Icons.schedule,
active: _scheduleEnabled,
),
],
),
const SizedBox(height: 12),
Text('Odometer: ${state?.odometer?.toStringAsFixed(1) ?? '0'} m'),
const SizedBox(height: 6),
Text(
'Latest: ${location != null ? location.event ?? 'location' : 'N/A'}',
),
if (location != null)
Text(
'Lat ${location.coords.latitude.toStringAsFixed(5)}, '
'Lng ${location.coords.longitude.toStringAsFixed(5)}',
),
const SizedBox(height: 6),
Text(
'Provider: ${provider?.authorizationStatus.name ?? 'unknown'} | '
'Availability: ${provider?.availability.name ?? 'unknown'}',
),
Text(
'Connectivity: ${connectivity?.networkType ?? 'unknown'} '
'(${connectivity?.connected == true ? 'online' : 'offline'})',
),
if (_lastActivity != null)
Text(
'Activity: ${_lastActivity!.type.name} '
'(${_lastActivity!.confidence}%)',
),
if (_lastHttp != null)
Text('Last HTTP: ${_lastHttp!.status} ok=${_lastHttp!.ok}'),
if (_lastGeofence != null)
Text(
'Last Geofence: ${_lastGeofence!.geofence.identifier} '
'${_lastGeofence!.action.name}',
),
if (_currentProfile != null)
Text('Profile: ${_currentProfile!.name}'),
if (_lastTripSummary != null)
Text(
'Last Trip: ${_lastTripSummary!.distanceMeters.toStringAsFixed(0)} m '
'in ${_lastTripSummary!.durationSeconds}s',
),
],
),
),
);
}
Widget _buildStatusChip({
required String label,
required IconData icon,
required bool active,
}) {
final color = active ? Theme.of(context).colorScheme.primary : Colors.grey;
return Chip(
avatar: Icon(icon, size: 16, color: color),
label: Text(label),
side: BorderSide(color: color.withValues(alpha: 0.4)),
backgroundColor: color.withValues(alpha: 0.1),
);
}
Widget _buildControlPanel() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Controls',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<TrackingPreset>(
initialValue: _selectedPreset,
decoration: const InputDecoration(
labelText: 'Tracking preset',
border: OutlineInputBorder(),
),
items: TrackingPreset.values
.map(
(preset) => DropdownMenuItem(
value: preset,
child: Text(_presetLabel(preset)),
),
)
.toList(),
onChanged: (preset) {
if (preset != null) {
_applyPreset(preset);
}
},
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: _startOrStopTracking,
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
label: Text(_isRunning ? 'Stop Tracking' : 'Start Tracking'),
style: FilledButton.styleFrom(
backgroundColor: _isRunning
? Colors.redAccent
: Theme.of(context).colorScheme.primary,
),
),
OutlinedButton.icon(
onPressed: _getCurrentPosition,
icon: const Icon(Icons.my_location),
label: const Text('Get Position'),
),
FilledButton.tonalIcon(
onPressed: _toggleSchedule,
icon: const Icon(Icons.schedule),
label: Text(
_scheduleEnabled ? 'Stop Schedule' : 'Start Schedule',
),
),
FilledButton.tonalIcon(
onPressed: _addDemoGeofence,
icon: const Icon(Icons.add_location_alt),
label: const Text('Add Geofence'),
),
OutlinedButton.icon(
onPressed: _removeAllGeofences,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear Geofences'),
),
FilledButton.tonalIcon(
onPressed: () => _applyProfile(TrackingProfile.offDuty),
icon: const Icon(Icons.bedtime_outlined),
label: const Text('Off Duty'),
),
FilledButton.tonalIcon(
onPressed: () => _applyProfile(TrackingProfile.standby),
icon: const Icon(Icons.pause_circle_outline),
label: const Text('Standby'),
),
FilledButton.tonalIcon(
onPressed: () => _applyProfile(TrackingProfile.enRoute),
icon: const Icon(Icons.navigation_outlined),
label: const Text('En Route'),
),
FilledButton.tonalIcon(
onPressed: () => _applyProfile(TrackingProfile.arrived),
icon: const Icon(Icons.flag_outlined),
label: const Text('Arrived'),
),
FilledButton.tonalIcon(
onPressed: _startTrip,
icon: const Icon(Icons.flag_outlined),
label: const Text('Start Trip'),
),
OutlinedButton.icon(
onPressed: _stopTrip,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Stop Trip'),
),
],
),
],
),
),
);
}
Widget _buildQuickStats() {
final entries = _eventCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Event Pulse',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
if (entries.isEmpty) const Text('No events recorded yet.'),
if (entries.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: entries
.map(
(entry) => Chip(
label: Text('${entry.key}: ${entry.value}'),
),
)
.toList(),
),
],
),
),
);
}
Widget _buildEventSummary() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total events: ${_events.length}',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'Max: $_maxEventEntries',
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
);
}
Widget _buildEventList() {
return ListView.separated(
itemCount: _events.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, int idx) {
final entry = _events[idx];
return ListTile(
leading: const Icon(Icons.location_pin),
title: Text(entry),
);
},
);
}
Widget _buildStoragePanel() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stored Locations',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonalIcon(
onPressed: _loadStoredLocations,
icon: const Icon(Icons.download),
label: const Text('Load'),
),
OutlinedButton.icon(
onPressed: _clearStoredLocations,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear'),
),
Chip(
label: Text('Count: ${_storedLocations.length}'),
),
],
),
if (_storedLocations.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Latest stored: ${_storedLocations.last.coords.latitude.toStringAsFixed(5)}, '
'${_storedLocations.last.coords.longitude.toStringAsFixed(5)}',
),
],
],
),
),
);
}
Widget _buildStoredList() {
if (_storedLocations.isEmpty) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _storedLocations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, int index) {
final location = _storedLocations[index];
return ListTile(
leading: const Icon(Icons.place_outlined),
title: Text(
'${location.coords.latitude.toStringAsFixed(5)}, '
'${location.coords.longitude.toStringAsFixed(5)}',
),
subtitle: Text(
location.timestamp.toIso8601String(),
),
trailing: Text(
'${location.coords.accuracy.toStringAsFixed(1)}m',
),
);
},
),
),
);
}
Widget _buildDiagnosticsPanel() {
final state = _lastState;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Diagnostics',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text('Enabled: ${state?.enabled ?? false}'),
Text('Moving: ${state?.isMoving ?? false}'),
Text('Scheduler: ${state?.schedulerEnabled ?? false}'),
Text('Odometer: ${state?.odometer?.toStringAsFixed(1) ?? '0'}'),
const SizedBox(height: 8),
if (_lastProvider != null)
Text(
'Provider status: ${_lastProvider!.authorizationStatus.name}',
),
if (_lastConnectivity != null)
Text(
'Network: ${_lastConnectivity!.networkType ?? 'unknown'} '
'(${_lastConnectivity!.connected ? 'online' : 'offline'})',
),
if (_lastNotificationAction != null)
Text('Notification action: $_lastNotificationAction'),
],
),
),
);
}
Widget _buildLogPanel() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Logs',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonalIcon(
onPressed: _loadLog,
icon: const Icon(Icons.article_outlined),
label: const Text('Load Log'),
),
OutlinedButton.icon(
onPressed: _emailLog,
icon: const Icon(Icons.email_outlined),
label: const Text('Email Log'),
),
],
),
if (_lastLog != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_lastLog!.isEmpty
? 'No logs yet.'
: _formatLogEntries(_lastLog!),
maxLines: 12,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
}
Widget _buildAdvancedTab() {
return SingleChildScrollView(
child: Column(
children: [
_buildBatteryOptimizationCard(),
_buildSpoofDetectionCard(),
_buildSignificantChangesCard(),
_buildErrorRecoveryCard(),
],
),
);
}
Widget _buildBatteryOptimizationCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.battery_charging_full),
const SizedBox(width: 8),
Text(
'Battery Optimization',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const SizedBox(height: 16),
if (_powerState != null) ...[
Text(
'Battery: ${_powerState!.batteryLevel}% '
'(${_powerState!.isCharging ? "Charging" : "Discharging"})',
),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: _refreshBatteryStats,
child: const Text('Refresh Stats'),
),
FilledButton.tonal(
onPressed: _benchmarkStatus == null ? _toggleBenchmark : null,
child: const Text('Start Benchmark'),
),
if (_benchmarkStatus != null)
FilledButton(
onPressed: _toggleBenchmark,
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Stop Benchmark'),
),
],
),
if (_batteryStats != null) ...[
const SizedBox(height: 16),
const Text('Stats:',
style: TextStyle(fontWeight: FontWeight.bold)),
Text(
'GPS On Time: ${(_batteryStats!.gpsOnTimePercent * 100).toStringAsFixed(1)}%'),
Text(
'Drain Rate: ${_batteryStats!.estimatedDrainPerHour?.toStringAsFixed(1) ?? "N/A"}%/hr'),
],
],
),
),
);
}
Widget _buildSpoofDetectionCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.security),
const SizedBox(width: 8),
Text(
'Spoof Detection',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
SwitchListTile(
title: const Text('Enable Detection'),
subtitle: const Text('Detect mock/spoofed locations'),
value: _spoofDetectionEnabled,
onChanged: (val) => _toggleSpoofDetection(),
),
],
),
),
);
}
Widget _buildSignificantChangesCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.compare_arrows),
const SizedBox(width: 8),
Text(
'Significant Changes',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
SwitchListTile(
title: const Text('Monitor Changes'),
subtitle: const Text('Ultra-low power (~500m)'),
value: _significantChangesEnabled,
onChanged: (val) => _toggleSignificantChanges(),
),
],
),
),
);
}
Widget _buildErrorRecoveryCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.healing),
const SizedBox(width: 8),
Text(
'Error Recovery',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _simulateError,
child: const Text('Simulate Network Error'),
),
],
),
),
);
}
Future<void> _refreshBatteryStats() async {
final state = await Locus.getPowerState();
final stats = await Locus.getBatteryStats();
setState(() {
_powerState = state;
_batteryStats = stats;
});
}
Future<void> _toggleBenchmark() async {
if (_benchmarkStatus == null) {
await Locus.startBatteryBenchmark();
setState(() => _benchmarkStatus = 'Running...');
_showSnackbar('Benchmark started');
} else {
await Locus.stopBatteryBenchmark();
setState(() => _benchmarkStatus = null);
_showSnackbar('Benchmark stopped');
}
}
Future<void> _toggleSpoofDetection() async {
final newValue = !_spoofDetectionEnabled;
await Locus.setSpoofDetection(
newValue ? SpoofDetectionConfig.high : SpoofDetectionConfig.disabled,
);
setState(() => _spoofDetectionEnabled = newValue);
_showSnackbar('Spoof detection ${newValue ? "enabled" : "disabled"}');
}
Future<void> _toggleSignificantChanges() async {
final newValue = !_significantChangesEnabled;
if (newValue) {
await Locus.startSignificantChangeMonitoring(
SignificantChangeConfig.defaults,
);
} else {
await Locus.stopSignificantChangeMonitoring();
}
setState(() => _significantChangesEnabled = newValue);
}
Future<void> _simulateError() async {
Locus.handleError(LocusError.networkError(
message: 'Simulated connection failure',
originalError: 'Simulated',
));
// The ErrorRecoveryManager logs this, so check logs
_showSnackbar('Simulated error injected');
}
String _formatLocationEvent(Location location, String label) {
final lat = location.coords.latitude.toStringAsFixed(5);
final lng = location.coords.longitude.toStringAsFixed(5);
return '$label: $lat, $lng (acc ${location.coords.accuracy.toStringAsFixed(1)}m)';
}
String _formatTimestamp(DateTime time) {
final hours = time.hour.toString().padLeft(2, '0');
final minutes = time.minute.toString().padLeft(2, '0');
final seconds = time.second.toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
String _formatLogEntries(List<LogEntry> entries) {
return entries.take(12).map((entry) {
final timestamp = _formatTimestamp(entry.timestamp.toLocal());
final tag = entry.tag;
final level = tag == null ? entry.level : '${entry.level}/$tag';
return '[$timestamp] $level ${entry.message}';
}).join('\n');
}
}