smart_location 0.0.5
smart_location: ^0.0.5 copied to clipboard
Smart Location is a Flutter location plugin for GPS tracking, real-time location updates, geofencing, distance calculation.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:smart_location/smart_location.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Smart Location Example',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.dark,
),
),
home: const MainScreen(),
debugShowCheckedModeBanner: false,
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
LocationData? _currentLocation;
bool _isLoading = false;
String _errorMsg = '';
StreamSubscription<LocationData>? _streamSubscription;
// Privacy Guard states
LocationData? _fuzzedLocation;
LocationData? _snappedLocation;
double _fuzzRadius = 500.0;
double _gridSize = 0.01;
// Route Recorder states
bool _isRecording = false;
bool _isPaused = false;
Duration _recordElapsed = Duration.zero;
Timer? _recordTimer;
List<LocationData> _recordedPath = [];
List<LocationData> _simplifiedPath = [];
double _recordedDistance = 0.0;
String _encodedPolyline = '';
GeoBounds? _routeBounds;
// Geofencing states
final List<GeofenceEvent> _geofenceEvents = [];
bool _isGeofenceMonitoring = false;
double _geofenceRadius = 50.0;
// Speed Monitoring states
final List<String> _speedLogs = [];
double _speedLimitKmh = 60.0;
bool _isSpeedMonitoring = false;
StreamSubscription<SpeedAlert>? _speedAlertSub;
StreamSubscription<GForceAlert>? _gForceAlertSub;
@override
void initState() {
super.initState();
_tabController = TabController(length: 6, vsync: this);
_initLocation();
}
Future<void> _initLocation() async {
setState(() {
_isLoading = true;
_errorMsg = '';
});
try {
await SmartLocation.ensureReady();
final loc = await SmartLocation.current();
setState(() {
_currentLocation = loc;
_isLoading = false;
});
_updatePrivacyStates(loc);
} catch (e) {
setState(() {
_errorMsg = e.toString();
_isLoading = false;
});
}
}
void _updatePrivacyStates(LocationData loc) {
setState(() {
_fuzzedLocation = PrivacyGuard.fuzz(loc, radiusInMeters: _fuzzRadius);
_snappedLocation = PrivacyGuard.snapToGrid(loc, gridSizeDegrees: _gridSize);
});
}
// Stream-based Live Updates
void _toggleLiveStream(bool enable) {
if (enable) {
_streamSubscription = SmartLocation.stream.listen((loc) {
setState(() {
_currentLocation = loc;
});
_updatePrivacyStates(loc);
}, onError: (err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Stream error: $err')),
);
});
} else {
_streamSubscription?.cancel();
_streamSubscription = null;
}
setState(() {});
}
// Route Recording triggers
void _startRecording() {
SmartLocation.recorder.start(SmartLocation.stream);
_recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_recordElapsed = SmartLocation.recorder.elapsedTime;
_recordedDistance = SmartLocation.recorder.totalDistanceInMeters;
});
});
setState(() {
_isRecording = true;
_isPaused = false;
_simplifiedPath.clear();
_recordedPath.clear();
_encodedPolyline = '';
_routeBounds = null;
});
}
void _pauseRecording() {
SmartLocation.recorder.pause();
setState(() {
_isPaused = true;
});
}
void _resumeRecording() {
SmartLocation.recorder.resume();
setState(() {
_isPaused = false;
});
}
void _stopRecording() {
_recordTimer?.cancel();
final path = SmartLocation.recorder.stop();
setState(() {
_isRecording = false;
_isPaused = false;
_recordedPath = path;
_recordElapsed = Duration.zero;
_recordedDistance = 0.0;
if (path.isNotEmpty) {
_encodedPolyline = SmartLocation.maps.encodePolyline(path);
_routeBounds = SmartLocation.maps.calculateBounds(path);
}
});
}
void _simplifyRecordedPath() {
if (_recordedPath.isEmpty) return;
final simplified = SmartLocation.simplifyPath(_recordedPath, 10.0); // 10m tolerance
setState(() {
_simplifiedPath = simplified;
if (simplified.isNotEmpty) {
_encodedPolyline = SmartLocation.maps.encodePolyline(simplified);
_routeBounds = SmartLocation.maps.calculateBounds(simplified);
}
});
}
// Geofencing triggers
void _setupGeofenceAndStart() {
if (_currentLocation == null) return;
SmartLocation.geofence.clearGeofences();
_geofenceEvents.clear();
final gf = Geofence(
id: "demo_center",
latitude: _currentLocation!.latitude,
longitude: _currentLocation!.longitude,
radiusInMeters: _geofenceRadius,
triggerTypes: {GeofenceTrigger.enter, GeofenceTrigger.exit, GeofenceTrigger.dwell},
dwellDuration: const Duration(seconds: 10),
);
SmartLocation.geofence.addGeofence(gf);
SmartLocation.geofence.startMonitoring(SmartLocation.stream);
SmartLocation.geofence.events.listen((event) {
setState(() {
_geofenceEvents.insert(0, event);
});
});
setState(() {
_isGeofenceMonitoring = true;
});
}
void _stopGeofenceMonitoring() {
SmartLocation.geofence.stopMonitoring();
setState(() {
_isGeofenceMonitoring = false;
});
}
// Speed Monitoring triggers
void _toggleSpeedMonitoring(bool enable) {
if (enable) {
_speedLogs.clear();
SmartLocation.speed.configure(speedLimitKmh: _speedLimitKmh);
SmartLocation.speed.startMonitoring(SmartLocation.stream);
_speedAlertSub = SmartLocation.speed.speedAlerts.listen((event) {
setState(() {
_speedLogs.insert(0, '[SPEED LIMIT ALERT] ${event.message}');
});
});
_gForceAlertSub = SmartLocation.speed.gForceAlerts.listen((event) {
setState(() {
_speedLogs.insert(0, '[BRAKE/ACCEL ALERT] ${event.message}');
});
});
} else {
SmartLocation.speed.stopMonitoring();
_speedAlertSub?.cancel();
_gForceAlertSub?.cancel();
}
setState(() {
_isSpeedMonitoring = enable;
});
}
// Simulates a driving route with acceleration and braking to demonstrate alerts in the UI
void _simulateTripAlerts() {
final streamController = StreamController<LocationData>();
final simulatedSpeedMonitor = SpeedMonitor();
simulatedSpeedMonitor.configure(speedLimitKmh: 50.0); // 50 km/h threshold
final List<String> simulatedLogs = [];
simulatedSpeedMonitor.speedAlerts.listen((e) => simulatedLogs.add('[SPEED WARNING] ${e.message}'));
simulatedSpeedMonitor.gForceAlerts.listen((e) => simulatedLogs.add('[BRAKE/ACCEL] ${e.message}'));
simulatedSpeedMonitor.startMonitoring(streamController.stream);
final t1 = DateTime.now();
final t2 = t1.add(const Duration(seconds: 1));
final t3 = t1.add(const Duration(seconds: 2));
// 1. Stable slow speed
streamController.add(LocationData(latitude: 0, longitude: 0, accuracy: 1, altitude: 0, speed: 8.0, speedAccuracy: 0, heading: 0, timestamp: t1));
// 2. High accel (8 m/s to 25 m/s = 28.8 km/h to 90 km/h) -> triggers speed and accel alert
Future.delayed(const Duration(milliseconds: 600), () {
streamController.add(LocationData(latitude: 0.001, longitude: 0.001, accuracy: 1, altitude: 0, speed: 25.0, speedAccuracy: 0, heading: 0, timestamp: t2));
});
// 3. Sudden brake (25 m/s to 3 m/s = 90 km/h to 10.8 km/h) -> triggers braking alert
Future.delayed(const Duration(milliseconds: 1200), () {
streamController.add(LocationData(latitude: 0.002, longitude: 0.002, accuracy: 1, altitude: 0, speed: 3.0, speedAccuracy: 0, heading: 0, timestamp: t3));
});
Future.delayed(const Duration(milliseconds: 1800), () {
simulatedSpeedMonitor.dispose();
streamController.close();
// Copy simulation logs to the UI logs
setState(() {
_speedLogs.insertAll(0, simulatedLogs.reversed);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Simulation trip finished! Alerts added to logs.')),
);
});
}
// Opens a bottom sheet displaying beautiful GeoJSON data
void _showGeoJsonExporterSheet(Map<String, dynamic> geoJsonMap) {
final prettyString = const JsonEncoder.withIndent(' ').convert(geoJsonMap);
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return Container(
height: MediaQuery.of(context).size.height * 0.7,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('GeoJSON Export Output', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: prettyString));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied GeoJSON to clipboard!')),
);
},
),
],
),
const Divider(),
Expanded(
child: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(8),
),
child: Text(
prettyString,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
},
);
}
@override
void dispose() {
_streamSubscription?.cancel();
_recordTimer?.cancel();
_tabController.dispose();
_speedAlertSub?.cancel();
_gForceAlertSub?.cancel();
_drEngine.stop();
SmartLocation.geofence.dispose();
SmartLocation.speed.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Smart Location Toolkit'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(icon: Icon(Icons.gps_fixed), text: 'Core GPS'),
Tab(icon: Icon(Icons.security), text: 'Privacy'),
Tab(icon: Icon(Icons.route), text: 'Recorder'),
Tab(icon: Icon(Icons.adjust), text: 'Geofence'),
Tab(icon: Icon(Icons.speed), text: 'Speed'),
Tab(icon: Icon(Icons.science), text: 'Advanced'),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMsg.isNotEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $_errorMsg', style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _initLocation, child: const Text('Retry')),
],
),
)
: TabBarView(
controller: _tabController,
children: [
_buildCoreGPSView(),
_buildPrivacyView(),
_buildRouteRecorderView(),
_buildGeofencingView(),
_buildSpeedMonitorView(),
_buildAdvancedFeaturesView(),
],
),
);
}
Widget _buildCoreGPSView() {
final loc = _currentLocation;
final isStreaming = _streamSubscription != null;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mock Location Banner
if (loc != null)
Card(
color: loc.isMocked ? Colors.red.shade900 : Colors.green.shade900,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
loc.isMocked ? Icons.warning : Icons.verified,
size: 32,
color: Colors.white,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.isMocked ? 'MOCKED GPS DETECTED' : 'SECURE GPS SIGNAL',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
loc.isMocked
? 'This location coordinates have been spoofed.'
: 'Coordinates are authentic from device hardware.',
style: const TextStyle(color: Colors.white70),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
if (loc != null) ...[
_buildDataCard('Latitude', '${loc.latitude}°'),
_buildDataCard('Longitude', '${loc.longitude}°'),
_buildDataCard('Accuracy', '${loc.accuracy.toStringAsFixed(1)} meters'),
_buildDataCard('Altitude', '${loc.altitude.toStringAsFixed(1)} meters'),
_buildDataCard('Speed', '${(loc.speed * 3.6).toStringAsFixed(1)} km/h'),
_buildDataCard('Heading', '${loc.heading.toStringAsFixed(0)}°'),
_buildDataCard('Last Updated', loc.timestamp?.toIso8601String() ?? 'Unknown'),
] else
const Center(child: Text('No location fetched yet.')),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _initLocation,
icon: const Icon(Icons.refresh),
label: const Text('Fetch One-Time'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () => _toggleLiveStream(!isStreaming),
icon: Icon(isStreaming ? Icons.gps_off : Icons.gps_fixed),
label: Text(isStreaming ? 'Stop Stream' : 'Live Stream'),
style: FilledButton.styleFrom(
backgroundColor: isStreaming ? Colors.orange.shade800 : null,
),
),
),
],
),
],
),
);
}
Widget _buildPrivacyView() {
final loc = _currentLocation;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Location Fuzzing & Privacy Guard',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Reduce precision or add random offsets to respect user privacy settings before uploading locations to your backend.',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
if (loc != null) ...[
_buildPrivacyCompareCard(
title: 'Location Fuzzing',
subtitle: 'Adds random displacement within a given radius.',
original: '${loc.latitude.toStringAsFixed(6)}, ${loc.longitude.toStringAsFixed(6)}',
modified: _fuzzedLocation != null
? '${_fuzzedLocation!.latitude.toStringAsFixed(6)}, ${_fuzzedLocation!.longitude.toStringAsFixed(6)}'
: 'N/A',
controlWidget: Slider(
value: _fuzzRadius,
min: 100,
max: 2000,
divisions: 19,
label: '${_fuzzRadius.round()}m',
onChanged: (val) {
setState(() {
_fuzzRadius = val;
});
_updatePrivacyStates(loc);
},
),
footerText: 'Fuzzing radius: ${_fuzzRadius.round()} meters',
),
const SizedBox(height: 16),
_buildPrivacyCompareCard(
title: 'Coarse Grid Snapping',
subtitle: 'Snaps coordinates to a grid resolution.',
original: '${loc.latitude.toStringAsFixed(6)}, ${loc.longitude.toStringAsFixed(6)}',
modified: _snappedLocation != null
? '${_snappedLocation!.latitude.toStringAsFixed(6)}, ${_snappedLocation!.longitude.toStringAsFixed(6)}'
: 'N/A',
controlWidget: Slider(
value: _gridSize,
min: 0.001,
max: 0.1,
divisions: 99,
label: _gridSize.toStringAsFixed(3),
onChanged: (val) {
setState(() {
_gridSize = val;
});
_updatePrivacyStates(loc);
},
),
footerText: 'Grid Resolution: ${_gridSize.toStringAsFixed(3)} degrees (~${(_gridSize * 111).toStringAsFixed(1)} km)',
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => _showGeoJsonExporterSheet(GeoJsonExporter.toPointMap(loc)),
icon: const Icon(Icons.code),
label: const Text('Export Current Location to GeoJSON Point'),
),
] else
const Center(child: Text('Please fetch core GPS location first.')),
],
),
);
}
Widget _buildRouteRecorderView() {
final showSimplificationStats = _recordedPath.isNotEmpty;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Route Recorder & Map Utilities',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatColumn('Duration', _formatDuration(_recordElapsed)),
_buildStatColumn('Distance', '${_recordedDistance.toStringAsFixed(1)} m'),
_buildStatColumn('Points', '${_isRecording ? SmartLocation.recorder.points.length : _recordedPath.length}'),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isRecording)
FilledButton.icon(
onPressed: _startRecording,
icon: const Icon(Icons.play_arrow),
label: const Text('Start Recording'),
),
if (_isRecording) ...[
IconButton.filled(
onPressed: _isPaused ? _resumeRecording : _pauseRecording,
icon: Icon(_isPaused ? Icons.play_arrow : Icons.pause),
style: IconButton.styleFrom(
backgroundColor: _isPaused ? Colors.green : Colors.orange,
),
),
const SizedBox(width: 16),
IconButton.filled(
onPressed: _stopRecording,
icon: const Icon(Icons.stop),
style: IconButton.styleFrom(backgroundColor: Colors.red),
),
]
],
)
],
),
),
),
const SizedBox(height: 24),
if (showSimplificationStats) ...[
ElevatedButton.icon(
onPressed: _simplifyRecordedPath,
icon: const Icon(Icons.compress),
label: const Text('Simplify Path (RDP Algorithm)'),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () => _showGeoJsonExporterSheet(
GeoJsonExporter.toLineStringMap(_simplifiedPath.isNotEmpty ? _simplifiedPath : _recordedPath),
),
icon: const Icon(Icons.code),
label: const Text('Export Path to GeoJSON LineString'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade800),
),
const SizedBox(height: 16),
Card(
color: Colors.blueGrey.shade900,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Compressed Path Stats:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
const Divider(height: 16),
Text('Original points: ${_recordedPath.length}'),
Text('Simplified points: ${_simplifiedPath.isNotEmpty ? _simplifiedPath.length : 'N/A'}'),
if (_simplifiedPath.isNotEmpty) ...[
Text(
'Compression savings: ${((1 - (_simplifiedPath.length / _recordedPath.length)) * 100).toStringAsFixed(1)}% space saved!',
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.bold),
),
],
const SizedBox(height: 12),
const Text('Computed GeoBounds:', style: TextStyle(fontWeight: FontWeight.bold)),
Text('SW Corners: ${_routeBounds?.southwestLatitude.toStringAsFixed(5)}, ${_routeBounds?.southwestLongitude.toStringAsFixed(5)}'),
Text('NE Corners: ${_routeBounds?.northeastLatitude.toStringAsFixed(5)}, ${_routeBounds?.northeastLongitude.toStringAsFixed(5)}'),
const SizedBox(height: 12),
Row(
children: [
const Text('Google Encoded Polyline:', style: TextStyle(fontWeight: FontWeight.bold)),
const Spacer(),
if (_encodedPolyline.isNotEmpty)
IconButton(
icon: const Icon(Icons.copy, size: 18),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
Clipboard.setData(ClipboardData(text: _encodedPolyline));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Polyline copied to clipboard!')),
);
},
),
],
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_encodedPolyline.isNotEmpty ? _encodedPolyline : 'Empty Path',
style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.yellow),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
] else
const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Start a recording and walk around to gather coordinate points. The path will compile statistics and compression bounds.',
textAlign: TextAlign.center,
),
),
)
],
),
);
}
Widget _buildGeofencingView() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Interactive Geofencing Monitor',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Adds a circular geofence around your initial position. Stream will trigger enter/exit/dwell events automatically.',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text('Radius: ${_geofenceRadius.round()} meters'),
),
Expanded(
flex: 2,
child: Slider(
value: _geofenceRadius,
min: 10,
max: 300,
divisions: 29,
label: '${_geofenceRadius.round()}m',
onChanged: _isGeofenceMonitoring
? null
: (val) {
setState(() {
_geofenceRadius = val;
});
},
),
),
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isGeofenceMonitoring ? _stopGeofenceMonitoring : _setupGeofenceAndStart,
icon: Icon(_isGeofenceMonitoring ? Icons.location_disabled : Icons.location_searching),
label: Text(_isGeofenceMonitoring ? 'Stop Monitoring' : 'Start Geofencing'),
style: ElevatedButton.styleFrom(
backgroundColor: _isGeofenceMonitoring ? Colors.red.shade900 : null,
),
),
const SizedBox(height: 20),
const Text(
'Transition Log History:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Expanded(
child: _geofenceEvents.isEmpty
? Center(
child: Text(
_isGeofenceMonitoring
? 'Waiting for movement events...'
: 'Press Start to begin tracking.',
style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _geofenceEvents.length,
itemBuilder: (context, idx) {
final ev = _geofenceEvents[idx];
IconData icon = Icons.info;
Color color = Colors.blue;
if (ev.triggerType == GeofenceTrigger.enter) {
icon = Icons.login;
color = Colors.green;
} else if (ev.triggerType == GeofenceTrigger.exit) {
icon = Icons.logout;
color = Colors.red;
} else if (ev.triggerType == GeofenceTrigger.dwell) {
icon = Icons.timer;
color = Colors.orange;
}
return Card(
child: ListTile(
leading: Icon(icon, color: color),
title: Text(
'Triggered: ${ev.triggerType.name.toUpperCase()}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('Geofence: ${ev.geofenceId}'),
trailing: Text(
'${ev.timestamp.hour.toString().padLeft(2, '0')}:${ev.timestamp.minute.toString().padLeft(2, '0')}:${ev.timestamp.second.toString().padLeft(2, '0')}',
),
),
);
},
),
),
],
),
);
}
Widget _buildSpeedMonitorView() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Speed Limit & Brake Monitor',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Checks for speed limits and flags G-force violations (sudden brake or acceleration bursts).',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text('Limit: ${_speedLimitKmh.round()} km/h'),
),
Expanded(
flex: 2,
child: Slider(
value: _speedLimitKmh,
min: 20,
max: 140,
divisions: 12,
label: '${_speedLimitKmh.round()} km/h',
onChanged: _isSpeedMonitoring
? null
: (val) {
setState(() {
_speedLimitKmh = val;
});
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => _toggleSpeedMonitoring(!_isSpeedMonitoring),
icon: Icon(_isSpeedMonitoring ? Icons.notifications_off : Icons.notifications_active),
label: Text(_isSpeedMonitoring ? 'Stop Monitor' : 'Start Monitor'),
style: FilledButton.styleFrom(
backgroundColor: _isSpeedMonitoring ? Colors.red.shade900 : null,
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _simulateTripAlerts,
icon: const Icon(Icons.directions_car),
label: const Text('Simulate Brake/Speed'),
),
),
],
),
const SizedBox(height: 20),
const Text(
'Speed Event Alerts Log:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Expanded(
child: _speedLogs.isEmpty
? const Center(
child: Text(
'No alerts logged. Tap "Simulate Brake/Speed" to trigger mock events.',
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _speedLogs.length,
itemBuilder: (context, idx) {
final log = _speedLogs[idx];
final isGForce = log.contains('BRAKE/ACCEL') || log.contains('[BRAKE/ACCEL]');
final isSpeed = log.contains('SPEED LIMIT') || log.contains('[SPEED WARNING]');
Color cardColor = Colors.grey.shade900;
if (log.contains('braking')) {
cardColor = Colors.orange.shade900.withOpacity(0.4);
} else if (isSpeed) {
cardColor = Colors.red.shade900.withOpacity(0.4);
}
return Card(
color: cardColor,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Text(
log,
style: const TextStyle(fontSize: 14),
),
),
);
},
),
),
],
),
);
}
// Advanced Phase 2 States
final List<String> _deadReckoningLogs = [];
bool _isDRSimulating = false;
final DeadReckoning _drEngine = DeadReckoning();
double _ecoBatteryPercent = 80.0;
bool _ecoHasNetwork = true;
final List<String> _indoorLogs = [];
double _rssiBeacon1 = -50.0;
double _rssiBeacon2 = -85.0;
double _rssiBeacon3 = -95.0;
final List<String> _mapMatchingLogs = [];
// Spatial Hashing states
final SpatialHashingGrid _demoSpatialGrid = SpatialHashingGrid(gridSize: 0.01);
final List<String> _spatialHashLogs = [];
bool _spatialGridInitialized = false;
double _queryLatOffset = 0.0;
double _queryLngOffset = 0.0;
void _runDeadReckoningSimulation() async {
if (_isDRSimulating) return;
setState(() {
_isDRSimulating = true;
_deadReckoningLogs.clear();
_deadReckoningLogs.add('Starting Dead Reckoning Simulation...');
_deadReckoningLogs.add('GPS stream started (1 update/sec).');
});
final streamController = StreamController<LocationData>();
_drEngine.start(
gpsStream: streamController.stream,
onUpdate: (loc) {
if (mounted) {
setState(() {
_deadReckoningLogs.insert(0, '[${loc.isMocked ? "DR EXTRAPOLATION" : "GPS RAW"}] Lat: ${loc.latitude.toStringAsFixed(6)}, Lng: ${loc.longitude.toStringAsFixed(6)}, Acc: ${loc.accuracy.toStringAsFixed(1)}m');
});
}
},
staleThreshold: const Duration(seconds: 2),
extrapolationInterval: const Duration(seconds: 1),
);
double lat = 37.7749;
double lng = -122.4194;
for (int i = 0; i < 3; i++) {
if (!mounted) return;
await Future.delayed(const Duration(seconds: 1));
lng += 0.00015;
streamController.add(LocationData(
latitude: lat,
longitude: lng,
accuracy: 8.0,
altitude: 0.0,
speed: 15.0, // 15 m/s (~54 km/h)
speedAccuracy: 0.0,
heading: 90.0, // East
timestamp: DateTime.now(),
));
}
if (!mounted) return;
setState(() {
_deadReckoningLogs.insert(0, '⚠️ GPS SIGNAL LOST! Extrapolating heading/speed...');
});
await Future.delayed(const Duration(seconds: 4));
if (!mounted) return;
setState(() {
_deadReckoningLogs.insert(0, '🔄 GPS SIGNAL REGAINED. Resuming clean updates...');
});
for (int i = 0; i < 2; i++) {
if (!mounted) return;
lat -= 0.0001;
streamController.add(LocationData(
latitude: lat,
longitude: lng,
accuracy: 9.0,
altitude: 0.0,
speed: 10.0,
speedAccuracy: 0.0,
heading: 180.0, // South
timestamp: DateTime.now(),
));
await Future.delayed(const Duration(seconds: 1));
}
_drEngine.stop();
await streamController.close();
if (mounted) {
setState(() {
_isDRSimulating = false;
_deadReckoningLogs.insert(0, 'Simulation complete.');
});
}
}
void _runMapMatchingDemo() {
setState(() {
_mapMatchingLogs.clear();
_mapMatchingLogs.add('Running Map Snapping & Smoothing Demo...');
});
final List<LocationData> rawNoisyPath = [
LocationData(latitude: 37.7749, longitude: -122.4194, accuracy: 5.0, altitude: 0, speed: 10, speedAccuracy: 0, heading: 0, timestamp: DateTime(2026, 1, 1, 12, 0, 0)),
LocationData(latitude: 37.7850, longitude: -122.4150, accuracy: 5.0, altitude: 0, speed: 10, speedAccuracy: 0, heading: 0, timestamp: DateTime(2026, 1, 1, 12, 0, 1)), // Jitter/Outlier (~1.2km jump in 1s)
LocationData(latitude: 37.7752, longitude: -122.4180, accuracy: 5.0, altitude: 0, speed: 10, speedAccuracy: 0, heading: 0, timestamp: DateTime(2026, 1, 1, 12, 0, 2)),
LocationData(latitude: 37.7755, longitude: -122.4170, accuracy: 5.0, altitude: 0, speed: 10, speedAccuracy: 0, heading: 0, timestamp: DateTime(2026, 1, 1, 12, 0, 3)),
];
final List<LocationData> roadSegments = [
LocationData(latitude: 37.7750, longitude: -122.4200, accuracy: 0, altitude: 0, speed: 0, speedAccuracy: 0, heading: 0),
LocationData(latitude: 37.7750, longitude: -122.4150, accuracy: 0, altitude: 0, speed: 0, speedAccuracy: 0, heading: 0),
];
final smoothed = SmartLocation.matching.smoothPath(rawNoisyPath);
final snapped = SmartLocation.matching.snapToNetwork(smoothed, roadSegments);
setState(() {
_mapMatchingLogs.add('Original Path size: ${rawNoisyPath.length} points.');
_mapMatchingLogs.add('Smoothed Path size: ${smoothed.length} points (filtered outlier!).');
_mapMatchingLogs.add('Snapping to Road Segment (Lat: 37.7750)...');
for (int i = 0; i < snapped.length; i++) {
final orig = smoothed[i];
final snap = snapped[i];
_mapMatchingLogs.add('Point ${i+1}: Raw(${orig.latitude.toStringAsFixed(6)}, ${orig.longitude.toStringAsFixed(6)}) ➔ Snapped(${snap.latitude.toStringAsFixed(6)}, ${snap.longitude.toStringAsFixed(6)})');
}
_mapMatchingLogs.add('Demo Finished!');
});
}
void _runIndoorCentroidLocate() {
final emulator = IndoorEmulator();
final lobby = LocationData(latitude: 37.774900, longitude: -122.419400, accuracy: 2.0, altitude: 0, speed: 0, speedAccuracy: 0, heading: 0);
final confRoom = LocationData(latitude: 37.775200, longitude: -122.419000, accuracy: 3.0, altitude: 0, speed: 0, speedAccuracy: 0, heading: 0);
final kitchen = LocationData(latitude: 37.774500, longitude: -122.418500, accuracy: 4.0, altitude: 0, speed: 0, speedAccuracy: 0, heading: 0);
emulator.registerBeacons({
'Lobby_AP': lobby,
'ConfRoom_AP': confRoom,
'Kitchen_AP': kitchen,
});
final signals = {
'Lobby_AP': _rssiBeacon1,
'ConfRoom_AP': _rssiBeacon2,
'Kitchen_AP': _rssiBeacon3,
};
final estimated = emulator.locateBySignals(signals);
setState(() {
_indoorLogs.clear();
_indoorLogs.add('Scanning WiFi Signal Strengths:');
signals.forEach((ssid, rssi) {
_indoorLogs.add(' • $ssid: ${rssi.round()} dBm');
});
_indoorLogs.add('Centroid estimation running...');
if (estimated != null) {
_indoorLogs.add('Estimated Indoor Coordinate:');
_indoorLogs.add('Lat: ${estimated.latitude.toStringAsFixed(6)}');
_indoorLogs.add('Lng: ${estimated.longitude.toStringAsFixed(6)}');
_indoorLogs.add('Accuracy base: ${estimated.accuracy.toStringAsFixed(1)}m');
} else {
_indoorLogs.add('WiFi signals too weak to locate.');
}
});
}
void _initSpatialGridDemo() {
_demoSpatialGrid.clear();
_spatialHashLogs.clear();
_spatialHashLogs.add('Initializing Spatial Hashing Grid...');
final double baseLat = 37.7749;
final double baseLng = -122.4194;
int count = 0;
// We add geofences at different offsets: from -0.05 to +0.05 in steps of 0.01
// This creates an 11x11 grid of 121 geofences
for (double dLat = -0.05; dLat <= 0.05; dLat += 0.01) {
for (double dLng = -0.05; dLng <= 0.05; dLng += 0.01) {
final id = 'gf_${(dLat * 100).round()}_${(dLng * 100).round()}';
final double lat = double.parse((baseLat + dLat).toStringAsFixed(6));
final double lng = double.parse((baseLng + dLng).toStringAsFixed(6));
final gf = Geofence(
id: id,
latitude: lat,
longitude: lng,
radiusInMeters: 50.0,
);
_demoSpatialGrid.addGeofence(gf);
count++;
}
}
setState(() {
_spatialGridInitialized = true;
_spatialHashLogs.add('Added $count geofences to the Spatial Hash Grid.');
_spatialHashLogs.add('Grid size resolution: 0.01 degrees (~1.1 km).');
_spatialHashLogs.add('Press "Query Nearby" to test lookup optimization.');
});
}
void _runSpatialGridQuery() {
if (!_spatialGridInitialized) {
_initSpatialGridDemo();
}
final double baseLat = 37.7749;
final double baseLng = -122.4194;
final double queryLat = double.parse((baseLat + _queryLatOffset).toStringAsFixed(6));
final double queryLng = double.parse((baseLng + _queryLngOffset).toStringAsFixed(6));
final queryKey = _demoSpatialGrid.getGridKey(queryLat, queryLng);
// Perform spatial query
final nearby = _demoSpatialGrid.getNearbyGeofences(queryLat, queryLng);
setState(() {
_spatialHashLogs.insert(0, '----------------------------------------');
_spatialHashLogs.insert(0, '🚀 Scan performance: Checked 9 grid cells.');
_spatialHashLogs.insert(0, '💡 Optimization: Avoided checking all other cells!');
_spatialHashLogs.insert(0, '📍 Found ${nearby.length} geofence(s) in local vicinity.');
if (nearby.isNotEmpty) {
for (final gf in nearby.take(5)) {
final dist = SmartLocation.distanceBetween(queryLat, queryLng, gf.latitude, gf.longitude);
_spatialHashLogs.insert(0, ' • ${gf.id} at (${gf.latitude}, ${gf.longitude}) ~${dist.round()}m away');
}
if (nearby.length > 5) {
_spatialHashLogs.insert(0, ' • ... and ${nearby.length - 5} more.');
}
}
_spatialHashLogs.insert(0, '🌐 Query Cell Key: "$queryKey"');
_spatialHashLogs.insert(0, '🔍 Querying coordinates: ($queryLat, $queryLng)');
});
}
Widget _buildAdvancedFeaturesView() {
final ecoAccuracy = SmartLocation.eco.getOptimizedAccuracy(
batteryLevelPercent: _ecoBatteryPercent,
hasNetwork: _ecoHasNetwork,
);
final ecoDistance = SmartLocation.eco.getOptimizedDistanceFilter(
batteryLevelPercent: _ecoBatteryPercent,
hasNetwork: _ecoHasNetwork,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Phase 2 Advanced Location Features',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.deepPurpleAccent),
),
const SizedBox(height: 8),
const Text(
'Demo panels for cutting edge algorithms including Dead Reckoning, Map Snapping, Hashing, Eco Optimization, and WiFi centroid localization.',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 16),
// 1. Dead Reckoning Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('1. Dead Reckoning kinematic engine', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text('Simulates 3 seconds of GPS updates, then introduces 4 seconds of GPS loss to trigger automated kinematic vector extrapolation (s = v * t).', style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isDRSimulating ? null : _runDeadReckoningSimulation,
icon: const Icon(Icons.rocket_launch),
label: Text(_isDRSimulating ? 'Simulating Extrapolation...' : 'Simulate GPS Signal Loss'),
),
const SizedBox(height: 12),
Container(
height: 120,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: _deadReckoningLogs.isEmpty
? const Center(child: Text('Press button to start simulation log', style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic)))
: ListView.builder(
itemCount: _deadReckoningLogs.length,
itemBuilder: (ctx, i) => Text(_deadReckoningLogs[i], style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
),
),
],
),
),
),
const SizedBox(height: 16),
// 2. Eco Optimizer Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('2. Battery & Network Eco-Optimizer', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Battery: ${_ecoBatteryPercent.round()}%'),
Slider(
value: _ecoBatteryPercent,
min: 1,
max: 100,
onChanged: (v) => setState(() => _ecoBatteryPercent = v),
),
],
),
),
const SizedBox(width: 16),
Column(
children: [
const Text('Network AP', style: TextStyle(fontSize: 12, color: Colors.grey)),
Switch(
value: _ecoHasNetwork,
onChanged: (v) => setState(() => _ecoHasNetwork = v),
),
],
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatColumn('Accuracy Setting', ecoAccuracy.name.toUpperCase()),
_buildStatColumn('Distance Filter', '$ecoDistance meters'),
],
),
],
),
),
),
const SizedBox(height: 16),
// 3. Indoor Wifi AP Centroid Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('3. Indoor WiFi Fingerprint Locator', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text('Simulates scanning Wi-Fi signal strengths to determine a weighted centroid of access points.', style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 12),
Text('Lobby AP (37.7749, -122.4194) Signal: ${_rssiBeacon1.round()} dBm'),
Slider(
value: _rssiBeacon1,
min: -100,
max: -30,
onChanged: (v) => setState(() => _rssiBeacon1 = v),
),
Text('ConfRoom AP (37.7752, -122.4190) Signal: ${_rssiBeacon2.round()} dBm'),
Slider(
value: _rssiBeacon2,
min: -100,
max: -30,
onChanged: (v) => setState(() => _rssiBeacon2 = v),
),
Text('Kitchen AP (37.7745, -122.4185) Signal: ${_rssiBeacon3.round()} dBm'),
Slider(
value: _rssiBeacon3,
min: -100,
max: -30,
onChanged: (v) => setState(() => _rssiBeacon3 = v),
),
ElevatedButton.icon(
onPressed: _runIndoorCentroidLocate,
icon: const Icon(Icons.wifi_find),
label: const Text('Calculate Indoor Position'),
),
const SizedBox(height: 12),
Container(
height: 120,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: _indoorLogs.isEmpty
? const Center(child: Text('Press button to calculate indoor position', style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic)))
: ListView.builder(
itemCount: _indoorLogs.length,
itemBuilder: (ctx, i) => Text(_indoorLogs[i], style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
),
),
],
),
),
),
const SizedBox(height: 16),
// 4. Map Snapping and Smoothing Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('4. Road Snapping & Path Smoothing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text('Filters a noisy coordinates path containing a GPS jump anomaly (15 km/h limit breached) and snaps the smoothed path onto a straight line segment.', style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _runMapMatchingDemo,
icon: const Icon(Icons.map),
label: const Text('Run Snapping & Smoothing Demo'),
),
const SizedBox(height: 12),
Container(
height: 120,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: _mapMatchingLogs.isEmpty
? const Center(child: Text('Press button to start map matching logs', style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic)))
: ListView.builder(
itemCount: _mapMatchingLogs.length,
itemBuilder: (ctx, i) => Text(_mapMatchingLogs[i], style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
),
),
],
),
),
),
const SizedBox(height: 16),
// 5. Spatial Hashing Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('5. Spatial Hashing Geofence Optimizer', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text(
'Buckets geofences into a spatial index to avoid O(N) linear comparisons. Only queries adjacent grid cells, reducing query complexity to O(1).',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Latitude Offset: ${_queryLatOffset.toStringAsFixed(3)}°'),
Slider(
value: _queryLatOffset,
min: -0.06,
max: 0.06,
divisions: 12,
onChanged: (v) {
setState(() {
_queryLatOffset = v;
});
},
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Longitude Offset: ${_queryLngOffset.toStringAsFixed(3)}°'),
Slider(
value: _queryLngOffset,
min: -0.06,
max: 0.06,
divisions: 12,
onChanged: (v) {
setState(() {
_queryLngOffset = v;
});
},
),
],
),
),
],
),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _initSpatialGridDemo,
icon: const Icon(Icons.grid_view),
label: const Text('Reset/Init Grid (121 Geofences)'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _runSpatialGridQuery,
icon: const Icon(Icons.search),
label: const Text('Query Nearby'),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 120,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: _spatialHashLogs.isEmpty
? const Center(
child: Text(
'Press "Query Nearby" or "Reset/Init" to populate log',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
),
)
: ListView.builder(
itemCount: _spatialHashLogs.length,
itemBuilder: (ctx, i) => Text(
_spatialHashLogs[i],
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
),
],
),
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildDataCard(String title, String val) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
trailing: Text(val, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
);
}
Widget _buildPrivacyCompareCard({
required String title,
required String subtitle,
required String original,
required String modified,
required Widget controlWidget,
required String footerText,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)),
const Divider(height: 20),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Original Coordinates', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text(original, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
],
),
),
const Icon(Icons.arrow_forward),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Obfuscated Coordinates', style: TextStyle(fontSize: 11, color: Colors.grey)),
Text(modified, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)),
],
),
),
],
),
const SizedBox(height: 12),
controlWidget,
Align(
alignment: Alignment.centerRight,
child: Text(footerText, style: const TextStyle(fontSize: 11, color: Colors.grey)),
),
],
),
),
);
}
Widget _buildStatColumn(String title, String val) {
return Column(
children: [
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Text(val, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
);
}
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final minutes = twoDigits(d.inMinutes.remainder(60));
final seconds = twoDigits(d.inSeconds.remainder(60));
return '$minutes:$seconds';
}
}