Complete v2.0 ultra-lightweight rebuild with premium color themes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2025-08-24 22:35:20 +09:00
parent 23b232f8f0
commit bab90a9830
37 changed files with 550 additions and 315 deletions
+52
View File
@@ -0,0 +1,52 @@
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 SpeedometerApp());
}
class SpeedometerApp extends StatelessWidget {
const SpeedometerApp({super.key});
@override
Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
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(),
);
},
),
);
}
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,
),
);
}
}
@@ -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,110 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum SpeedUnit { kmh, mph, knots }
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 {
switch (_speedUnit) {
case SpeedUnit.kmh:
return 'km/h';
case SpeedUnit.mph:
return 'mph';
case SpeedUnit.knots:
return 'knots';
}
}
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) {
switch (_speedUnit) {
case SpeedUnit.kmh:
return speedMps * 3.6;
case SpeedUnit.mph:
return speedMps * 2.237;
case SpeedUnit.knots:
return speedMps * 1.944;
}
}
}
@@ -0,0 +1,198 @@
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);
}
},
),
RadioListTile<SpeedUnit>(
title: const Text('Knots'),
value: SpeedUnit.knots,
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,187 @@
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 '../widgets/speed_unit_selector.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: Column(
children: [
Expanded(
child: _buildSpeedometer(locationProvider, settingsProvider),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0),
child: const SpeedUnitSelector(),
),
],
),
),
Expanded(
flex: 1,
child: InfoPanel(
locationProvider: locationProvider,
settingsProvider: settingsProvider,
),
),
],
);
},
),
);
}
Widget _buildSpeedometer(LocationProvider locationProvider, SettingsProvider settingsProvider) {
final speed = settingsProvider.convertSpeed(locationProvider.currentSpeed);
return GestureDetector(
onTap: () {
final newStyle = settingsProvider.speedometerStyle == SpeedometerStyle.digital
? SpeedometerStyle.analog
: SpeedometerStyle.digital;
settingsProvider.setSpeedometerStyle(newStyle);
},
child: settingsProvider.speedometerStyle == SpeedometerStyle.digital
? DigitalSpeedometer(
speed: speed,
unit: settingsProvider.speedUnitString,
)
: 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),
),
),
],
);
}
}
+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,
),
),
],
),
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
class SpeedUnitSelector extends StatelessWidget {
const SpeedUnitSelector({super.key});
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settings, child) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: SpeedUnit.values.map((unit) {
final isSelected = settings.speedUnit == unit;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton(
onPressed: () => settings.setSpeedUnit(unit),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
foregroundColor: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
elevation: isSelected ? 4 : 1,
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: Text(
_getUnitDisplayText(unit),
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
),
),
);
}).toList(),
);
},
);
}
String _getUnitDisplayText(SpeedUnit unit) {
switch (unit) {
case SpeedUnit.kmh:
return 'km/h';
case SpeedUnit.mph:
return 'mph';
case SpeedUnit.knots:
return 'knots';
}
}
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+51
View File
@@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Location permissions for GPS speedometer -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Optional: for background location access -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<application
android:label="gps_speedo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,34 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.baseflow.permissionhandler.PermissionHandlerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin permission_handler_android, com.baseflow.permissionhandler.PermissionHandlerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
}
}
}
@@ -0,0 +1,5 @@
package com.example.gps_speedo
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>