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
+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,
),
),
],
),
),
);
}
}