Implement complete GPS speedometer functionality
Features implemented: - Real-time GPS speed tracking with permission handling - Customizable speed units (km/h, mph) with persistent settings - Digital and analog speedometer display styles - Dark/light theme support with system integration - Optional metrics: coordinates, distance, trip time, GPS accuracy - Trip reset functionality and error handling - Modular architecture with Provider state management Technical details: - Added dependencies: geolocator, permission_handler, provider, shared_preferences - Configured Android location permissions (FINE_LOCATION, COARSE_LOCATION) - Portrait orientation lock for mobile-first experience - Extensible settings system for future customization options - Clean separation of concerns: providers, screens, widgets 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+34
-109
@@ -1,122 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'providers/location_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'screens/speedometer_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
runApp(const SpeedometerApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
class SpeedometerApp extends StatelessWidget {
|
||||
const SpeedometerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => LocationProvider()),
|
||||
],
|
||||
child: Consumer<SettingsProvider>(
|
||||
builder: (context, settings, child) {
|
||||
return MaterialApp(
|
||||
title: 'GPS Speedometer',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: _buildTheme(settings.isDarkMode),
|
||||
home: const SpeedometerScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildTheme(bool isDarkMode) {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: isDarkMode ? Brightness.dark : Brightness.light,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: isDarkMode ? Brightness.dark : Brightness.light,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class LocationProvider extends ChangeNotifier {
|
||||
Position? _currentPosition;
|
||||
double _currentSpeed = 0.0;
|
||||
double _totalDistance = 0.0;
|
||||
DateTime? _tripStartTime;
|
||||
bool _isTracking = false;
|
||||
bool _hasLocationPermission = false;
|
||||
String _errorMessage = '';
|
||||
StreamSubscription<Position>? _positionStreamSubscription;
|
||||
Position? _previousPosition;
|
||||
|
||||
Position? get currentPosition => _currentPosition;
|
||||
double get currentSpeed => _currentSpeed;
|
||||
double get totalDistance => _totalDistance;
|
||||
DateTime? get tripStartTime => _tripStartTime;
|
||||
bool get isTracking => _isTracking;
|
||||
bool get hasLocationPermission => _hasLocationPermission;
|
||||
String get errorMessage => _errorMessage;
|
||||
|
||||
double? get latitude => _currentPosition?.latitude;
|
||||
double? get longitude => _currentPosition?.longitude;
|
||||
double? get accuracy => _currentPosition?.accuracy;
|
||||
|
||||
Duration get tripDuration {
|
||||
if (_tripStartTime == null) return Duration.zero;
|
||||
return DateTime.now().difference(_tripStartTime!);
|
||||
}
|
||||
|
||||
Future<void> initializeLocation() async {
|
||||
await _checkLocationPermission();
|
||||
if (_hasLocationPermission) {
|
||||
await _startLocationTracking();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkLocationPermission() async {
|
||||
final permission = await Permission.locationWhenInUse.status;
|
||||
|
||||
if (permission.isDenied) {
|
||||
final result = await Permission.locationWhenInUse.request();
|
||||
_hasLocationPermission = result.isGranted;
|
||||
} else if (permission.isPermanentlyDenied) {
|
||||
_hasLocationPermission = false;
|
||||
_errorMessage = 'Location permission is permanently denied. Please enable it in settings.';
|
||||
} else {
|
||||
_hasLocationPermission = permission.isGranted;
|
||||
}
|
||||
|
||||
if (!_hasLocationPermission) {
|
||||
_errorMessage = 'Location permission is required for the speedometer to work.';
|
||||
} else {
|
||||
_errorMessage = '';
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _startLocationTracking() async {
|
||||
if (!_hasLocationPermission) return;
|
||||
|
||||
try {
|
||||
final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!isLocationServiceEnabled) {
|
||||
_errorMessage = 'Location services are disabled. Please enable them.';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
const locationSettings = LocationSettings(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
distanceFilter: 0,
|
||||
);
|
||||
|
||||
_positionStreamSubscription = Geolocator.getPositionStream(
|
||||
locationSettings: locationSettings,
|
||||
).listen(
|
||||
_onLocationUpdate,
|
||||
onError: (error) {
|
||||
_errorMessage = 'Location error: $error';
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
_isTracking = true;
|
||||
_tripStartTime = DateTime.now();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_errorMessage = 'Failed to start location tracking: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _onLocationUpdate(Position position) {
|
||||
_currentPosition = position;
|
||||
_currentSpeed = position.speed;
|
||||
|
||||
if (_previousPosition != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
_previousPosition!.latitude,
|
||||
_previousPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
_totalDistance += distance;
|
||||
}
|
||||
|
||||
_previousPosition = position;
|
||||
_errorMessage = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetTrip() {
|
||||
_totalDistance = 0.0;
|
||||
_tripStartTime = DateTime.now();
|
||||
_previousPosition = _currentPosition;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> stopTracking() async {
|
||||
await _positionStreamSubscription?.cancel();
|
||||
_positionStreamSubscription = null;
|
||||
_isTracking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopTracking();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum SpeedUnit { kmh, mph }
|
||||
enum SpeedometerStyle { digital, analog }
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
bool _isDarkMode = false;
|
||||
SpeedUnit _speedUnit = SpeedUnit.kmh;
|
||||
SpeedometerStyle _speedometerStyle = SpeedometerStyle.digital;
|
||||
bool _showCoordinates = false;
|
||||
bool _showDistance = false;
|
||||
bool _showTripTime = false;
|
||||
bool _showAccuracy = false;
|
||||
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
SpeedUnit get speedUnit => _speedUnit;
|
||||
SpeedometerStyle get speedometerStyle => _speedometerStyle;
|
||||
bool get showCoordinates => _showCoordinates;
|
||||
bool get showDistance => _showDistance;
|
||||
bool get showTripTime => _showTripTime;
|
||||
bool get showAccuracy => _showAccuracy;
|
||||
|
||||
String get speedUnitString => _speedUnit == SpeedUnit.kmh ? 'km/h' : 'mph';
|
||||
|
||||
SettingsProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isDarkMode = prefs.getBool('isDarkMode') ?? false;
|
||||
_speedUnit = SpeedUnit.values[prefs.getInt('speedUnit') ?? 0];
|
||||
_speedometerStyle = SpeedometerStyle.values[prefs.getInt('speedometerStyle') ?? 0];
|
||||
_showCoordinates = prefs.getBool('showCoordinates') ?? false;
|
||||
_showDistance = prefs.getBool('showDistance') ?? false;
|
||||
_showTripTime = prefs.getBool('showTripTime') ?? false;
|
||||
_showAccuracy = prefs.getBool('showAccuracy') ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setDarkMode(bool value) async {
|
||||
_isDarkMode = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isDarkMode', value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setSpeedUnit(SpeedUnit unit) async {
|
||||
_speedUnit = unit;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('speedUnit', unit.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setSpeedometerStyle(SpeedometerStyle style) async {
|
||||
_speedometerStyle = style;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('speedometerStyle', style.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowCoordinates(bool value) async {
|
||||
_showCoordinates = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('showCoordinates', value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowDistance(bool value) async {
|
||||
_showDistance = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('showDistance', value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowTripTime(bool value) async {
|
||||
_showTripTime = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('showTripTime', value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowAccuracy(bool value) async {
|
||||
_showAccuracy = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('showAccuracy', value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
double convertSpeed(double speedMps) {
|
||||
if (_speedUnit == SpeedUnit.kmh) {
|
||||
return speedMps * 3.6;
|
||||
} else {
|
||||
return speedMps * 2.237;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: Consumer<SettingsProvider>(
|
||||
builder: (context, settings, child) {
|
||||
return ListView(
|
||||
children: [
|
||||
_buildSectionHeader(context, 'Display'),
|
||||
SwitchListTile(
|
||||
title: const Text('Dark Mode'),
|
||||
subtitle: const Text('Switch between light and dark themes'),
|
||||
value: settings.isDarkMode,
|
||||
onChanged: settings.setDarkMode,
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: const Text('Speed Unit'),
|
||||
subtitle: Text('Current: ${settings.speedUnitString}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSpeedUnitDialog(context, settings),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: const Text('Speedometer Style'),
|
||||
subtitle: Text(settings.speedometerStyle == SpeedometerStyle.digital
|
||||
? 'Digital Display'
|
||||
: 'Analog Gauge'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSpeedometerStyleDialog(context, settings),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
_buildSectionHeader(context, 'Information Display'),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Show Coordinates'),
|
||||
subtitle: const Text('Display current latitude and longitude'),
|
||||
value: settings.showCoordinates,
|
||||
onChanged: settings.setShowCoordinates,
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Show Distance'),
|
||||
subtitle: const Text('Display total trip distance'),
|
||||
value: settings.showDistance,
|
||||
onChanged: settings.setShowDistance,
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Show Trip Time'),
|
||||
subtitle: const Text('Display elapsed trip time'),
|
||||
value: settings.showTripTime,
|
||||
onChanged: settings.setShowTripTime,
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Show GPS Accuracy'),
|
||||
subtitle: const Text('Display current GPS accuracy'),
|
||||
value: settings.showAccuracy,
|
||||
onChanged: settings.setShowAccuracy,
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
_buildSectionHeader(context, 'About'),
|
||||
|
||||
ListTile(
|
||||
title: const Text('GPS Speedometer'),
|
||||
subtitle: const Text('Version 1.0.0\nCreated with Claude Code'),
|
||||
leading: const Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSpeedUnitDialog(BuildContext context, SettingsProvider settings) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Speed Unit'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<SpeedUnit>(
|
||||
title: const Text('Kilometers per hour (km/h)'),
|
||||
value: SpeedUnit.kmh,
|
||||
groupValue: settings.speedUnit,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settings.setSpeedUnit(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<SpeedUnit>(
|
||||
title: const Text('Miles per hour (mph)'),
|
||||
value: SpeedUnit.mph,
|
||||
groupValue: settings.speedUnit,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settings.setSpeedUnit(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSpeedometerStyleDialog(BuildContext context, SettingsProvider settings) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Speedometer Style'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<SpeedometerStyle>(
|
||||
title: const Text('Digital Display'),
|
||||
subtitle: const Text('Clean numeric display'),
|
||||
value: SpeedometerStyle.digital,
|
||||
groupValue: settings.speedometerStyle,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settings.setSpeedometerStyle(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<SpeedometerStyle>(
|
||||
title: const Text('Analog Gauge'),
|
||||
subtitle: const Text('Classic circular speedometer'),
|
||||
value: SpeedometerStyle.analog,
|
||||
groupValue: settings.speedometerStyle,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settings.setSpeedometerStyle(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/location_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../widgets/digital_speedometer.dart';
|
||||
import '../widgets/analog_speedometer.dart';
|
||||
import '../widgets/info_panel.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class SpeedometerScreen extends StatefulWidget {
|
||||
const SpeedometerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpeedometerScreen> createState() => _SpeedometerScreenState();
|
||||
}
|
||||
|
||||
class _SpeedometerScreenState extends State<SpeedometerScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<LocationProvider>().initializeLocation();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('GPS Speedometer'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
context.read<LocationProvider>().resetTrip();
|
||||
},
|
||||
tooltip: 'Reset Trip',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
tooltip: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer2<LocationProvider, SettingsProvider>(
|
||||
builder: (context, locationProvider, settingsProvider, child) {
|
||||
if (!locationProvider.hasLocationPermission) {
|
||||
return _buildPermissionError(locationProvider.errorMessage);
|
||||
}
|
||||
|
||||
if (locationProvider.errorMessage.isNotEmpty) {
|
||||
return _buildError(locationProvider.errorMessage);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _buildSpeedometer(locationProvider, settingsProvider),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: InfoPanel(
|
||||
locationProvider: locationProvider,
|
||||
settingsProvider: settingsProvider,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpeedometer(LocationProvider locationProvider, SettingsProvider settingsProvider) {
|
||||
final speed = settingsProvider.convertSpeed(locationProvider.currentSpeed);
|
||||
|
||||
if (settingsProvider.speedometerStyle == SpeedometerStyle.digital) {
|
||||
return DigitalSpeedometer(
|
||||
speed: speed,
|
||||
unit: settingsProvider.speedUnitString,
|
||||
);
|
||||
} else {
|
||||
return AnalogSpeedometer(
|
||||
speed: speed,
|
||||
unit: settingsProvider.speedUnitString,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPermissionError(String message) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_off,
|
||||
size: 64,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Location Permission Required',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<LocationProvider>().initializeLocation();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(String message) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'GPS Error',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<LocationProvider>().initializeLocation();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnalogSpeedometer extends StatelessWidget {
|
||||
final double speed;
|
||||
final String unit;
|
||||
|
||||
const AnalogSpeedometer({
|
||||
super.key,
|
||||
required this.speed,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 250,
|
||||
height: 250,
|
||||
child: CustomPaint(
|
||||
painter: SpeedometerPainter(
|
||||
speed: speed,
|
||||
theme: Theme.of(context),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
speed.toStringAsFixed(0),
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
unit,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeedometerPainter extends CustomPainter {
|
||||
final double speed;
|
||||
final ThemeData theme;
|
||||
static const double maxSpeed = 120.0;
|
||||
|
||||
const SpeedometerPainter({
|
||||
required this.speed,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 20;
|
||||
|
||||
_drawSpeedometerArc(canvas, center, radius);
|
||||
_drawTicks(canvas, center, radius);
|
||||
_drawNeedle(canvas, center, radius);
|
||||
_drawCenterCircle(canvas, center);
|
||||
}
|
||||
|
||||
void _drawSpeedometerArc(Canvas canvas, Offset center, double radius) {
|
||||
final paint = Paint()
|
||||
..color = theme.colorScheme.outline.withOpacity(0.3)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const startAngle = pi * 0.75;
|
||||
const sweepAngle = pi * 1.5;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
startAngle,
|
||||
sweepAngle,
|
||||
false,
|
||||
paint,
|
||||
);
|
||||
|
||||
final progressPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final progress = (speed / maxSpeed).clamp(0.0, 1.0);
|
||||
final progressSweep = sweepAngle * progress;
|
||||
|
||||
if (progress > 0) {
|
||||
progressPaint.color = _getSpeedColor(speed);
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
startAngle,
|
||||
progressSweep,
|
||||
false,
|
||||
progressPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawTicks(Canvas canvas, Offset center, double radius) {
|
||||
final paint = Paint()
|
||||
..color = theme.colorScheme.onSurface
|
||||
..strokeWidth = 2;
|
||||
|
||||
const tickCount = 13;
|
||||
const startAngle = pi * 0.75;
|
||||
const sweepAngle = pi * 1.5;
|
||||
|
||||
for (int i = 0; i < tickCount; i++) {
|
||||
final angle = startAngle + (sweepAngle * i / (tickCount - 1));
|
||||
final isMainTick = i % 3 == 0;
|
||||
|
||||
final outerRadius = radius - 5;
|
||||
final innerRadius = outerRadius - (isMainTick ? 15 : 8);
|
||||
|
||||
final outerPoint = Offset(
|
||||
center.dx + cos(angle) * outerRadius,
|
||||
center.dy + sin(angle) * outerRadius,
|
||||
);
|
||||
|
||||
final innerPoint = Offset(
|
||||
center.dx + cos(angle) * innerRadius,
|
||||
center.dy + sin(angle) * innerRadius,
|
||||
);
|
||||
|
||||
paint.strokeWidth = isMainTick ? 3 : 2;
|
||||
canvas.drawLine(outerPoint, innerPoint, paint);
|
||||
|
||||
if (isMainTick) {
|
||||
final speed = (maxSpeed * i / (tickCount - 1)).round();
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: speed.toString(),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
|
||||
final textRadius = innerRadius - 15;
|
||||
final textOffset = Offset(
|
||||
center.dx + cos(angle) * textRadius - textPainter.width / 2,
|
||||
center.dy + sin(angle) * textRadius - textPainter.height / 2,
|
||||
);
|
||||
|
||||
textPainter.paint(canvas, textOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawNeedle(Canvas canvas, Offset center, double radius) {
|
||||
final progress = (speed / maxSpeed).clamp(0.0, 1.0);
|
||||
const startAngle = pi * 0.75;
|
||||
const sweepAngle = pi * 1.5;
|
||||
final needleAngle = startAngle + (sweepAngle * progress);
|
||||
|
||||
final needlePaint = Paint()
|
||||
..color = _getSpeedColor(speed)
|
||||
..strokeWidth = 4
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final needleLength = radius * 0.7;
|
||||
final needleEnd = Offset(
|
||||
center.dx + cos(needleAngle) * needleLength,
|
||||
center.dy + sin(needleAngle) * needleLength,
|
||||
);
|
||||
|
||||
canvas.drawLine(center, needleEnd, needlePaint);
|
||||
}
|
||||
|
||||
void _drawCenterCircle(Canvas canvas, Offset center) {
|
||||
final paint = Paint()
|
||||
..color = theme.colorScheme.primary
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(center, 8, paint);
|
||||
}
|
||||
|
||||
Color _getSpeedColor(double speed) {
|
||||
if (speed < 30) {
|
||||
return Colors.green;
|
||||
} else if (speed < 60) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return oldDelegate is SpeedometerPainter && oldDelegate.speed != speed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DigitalSpeedometer extends StatelessWidget {
|
||||
final double speed;
|
||||
final String unit;
|
||||
|
||||
const DigitalSpeedometer({
|
||||
super.key,
|
||||
required this.speed,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final speedText = speed.toStringAsFixed(0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: theme.colorScheme.surface,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
speedText,
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getSpeedColor(speed, theme),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
unit,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSpeedIndicator(speed, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getSpeedColor(double speed, ThemeData theme) {
|
||||
if (speed < 30) {
|
||||
return Colors.green;
|
||||
} else if (speed < 60) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSpeedIndicator(double speed, ThemeData theme) {
|
||||
final maxSpeed = 120.0;
|
||||
final progress = (speed / maxSpeed).clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Speed Progress',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 200,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: _getSpeedColor(speed, theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${(progress * 100).toStringAsFixed(0)}%',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../providers/location_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
|
||||
class InfoPanel extends StatelessWidget {
|
||||
final LocationProvider locationProvider;
|
||||
final SettingsProvider settingsProvider;
|
||||
|
||||
const InfoPanel({
|
||||
super.key,
|
||||
required this.locationProvider,
|
||||
required this.settingsProvider,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (settingsProvider.showCoordinates ||
|
||||
settingsProvider.showDistance ||
|
||||
settingsProvider.showTripTime ||
|
||||
settingsProvider.showAccuracy)
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.5,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
children: _buildInfoCards(context),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Enable additional metrics in settings',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildInfoCards(BuildContext context) {
|
||||
final cards = <Widget>[];
|
||||
|
||||
if (settingsProvider.showCoordinates) {
|
||||
cards.add(_buildInfoCard(
|
||||
context,
|
||||
'Location',
|
||||
'${locationProvider.latitude?.toStringAsFixed(6) ?? '--'}\n${locationProvider.longitude?.toStringAsFixed(6) ?? '--'}',
|
||||
Icons.location_on,
|
||||
));
|
||||
}
|
||||
|
||||
if (settingsProvider.showDistance) {
|
||||
final distanceKm = locationProvider.totalDistance / 1000;
|
||||
final distanceText = distanceKm < 1
|
||||
? '${locationProvider.totalDistance.toStringAsFixed(0)} m'
|
||||
: '${distanceKm.toStringAsFixed(2)} km';
|
||||
|
||||
cards.add(_buildInfoCard(
|
||||
context,
|
||||
'Distance',
|
||||
distanceText,
|
||||
Icons.straighten,
|
||||
));
|
||||
}
|
||||
|
||||
if (settingsProvider.showTripTime) {
|
||||
final duration = locationProvider.tripDuration;
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
|
||||
String timeText;
|
||||
if (hours > 0) {
|
||||
timeText = '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
timeText = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
cards.add(_buildInfoCard(
|
||||
context,
|
||||
'Trip Time',
|
||||
timeText,
|
||||
Icons.timer,
|
||||
));
|
||||
}
|
||||
|
||||
if (settingsProvider.showAccuracy) {
|
||||
final accuracy = locationProvider.accuracy;
|
||||
final accuracyText = accuracy != null
|
||||
? '±${accuracy.toStringAsFixed(0)} m'
|
||||
: '--';
|
||||
|
||||
cards.add(_buildInfoCard(
|
||||
context,
|
||||
'Accuracy',
|
||||
accuracyText,
|
||||
Icons.gps_fixed,
|
||||
));
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, String title, String value, IconData icon) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user