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:
Mikkeli Matlock
2025-08-20 23:30:14 +09:00
parent e0f643d846
commit f5239d4a2a
16 changed files with 1435 additions and 126 deletions
+34 -109
View File
@@ -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.
);
}
}
+136
View File
@@ -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();
}
}
+98
View File
@@ -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;
}
}
}
+186
View File
@@ -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'),
),
],
),
);
}
}
+170
View File
@@ -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'),
),
],
),
),
);
}
}
+215
View File
@@ -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;
}
}
+113
View File
@@ -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),
),
),
],
);
}
}
+150
View File
@@ -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,
),
),
],
),
),
);
}
}