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:
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ColorTheme {
|
||||
final Color background;
|
||||
final Color speedText;
|
||||
final Color headingText;
|
||||
final Color unitText;
|
||||
final String name;
|
||||
|
||||
const ColorTheme({
|
||||
required this.background,
|
||||
required this.speedText,
|
||||
required this.headingText,
|
||||
required this.unitText,
|
||||
required this.name,
|
||||
});
|
||||
}
|
||||
|
||||
class ColorThemes {
|
||||
static const List<ColorTheme> themes = [
|
||||
ColorTheme(
|
||||
name: 'Classic Dark',
|
||||
background: Color(0xFF121212),
|
||||
speedText: Color(0xFF4CAF50),
|
||||
headingText: Color(0xFFFF9800),
|
||||
unitText: Color(0xFF9E9E9E),
|
||||
),
|
||||
ColorTheme(
|
||||
name: 'Electric Blue',
|
||||
background: Color(0xFF0D1421),
|
||||
speedText: Color(0xFF00E5FF),
|
||||
headingText: Color(0xFFFFEB3B),
|
||||
unitText: Color(0xFF78909C),
|
||||
),
|
||||
ColorTheme(
|
||||
name: 'Racing Red',
|
||||
background: Color(0xFF1A0A0A),
|
||||
speedText: Color(0xFFFF1744),
|
||||
headingText: Color(0xFFFFFFFF),
|
||||
unitText: Color(0xFFB0BEC5),
|
||||
),
|
||||
];
|
||||
|
||||
static ColorTheme getTheme(int index) {
|
||||
return themes[index % themes.length];
|
||||
}
|
||||
|
||||
static int getNextThemeIndex(int currentIndex) {
|
||||
return (currentIndex + 1) % themes.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class GpsService {
|
||||
static Stream<Position> get positionStream {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
distanceFilter: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> requestPermissions() async {
|
||||
final permission = await Permission.locationWhenInUse.request();
|
||||
return permission.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> isLocationServiceEnabled() async {
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
}
|
||||
|
||||
static String getCompassDirection(double heading) {
|
||||
if (heading < 0) return 'N/A';
|
||||
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((heading + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
static String formatHeading(double heading) {
|
||||
if (heading < 0) return 'N/A';
|
||||
return '${getCompassDirection(heading)} ${heading.round()}°';
|
||||
}
|
||||
}
|
||||
+190
-40
@@ -1,52 +1,202 @@
|
||||
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';
|
||||
import 'dart:async';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'speed_units.dart';
|
||||
import 'gps_service.dart';
|
||||
import 'color_themes.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const SpeedometerApp());
|
||||
runApp(const GpsSpeedoApp());
|
||||
}
|
||||
|
||||
class SpeedometerApp extends StatelessWidget {
|
||||
const SpeedometerApp({super.key});
|
||||
// Entry point for future overlay mode
|
||||
void mainOverlay() {
|
||||
// TODO: Initialize overlay-specific setup
|
||||
runApp(const GpsSpeedoApp(isOverlayMode: true));
|
||||
}
|
||||
|
||||
class GpsSpeedoApp extends StatelessWidget {
|
||||
final bool isOverlayMode;
|
||||
|
||||
const GpsSpeedoApp({super.key, this.isOverlayMode = false});
|
||||
|
||||
@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,
|
||||
return MaterialApp(
|
||||
title: 'GPS Speedo',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorSchemeSeed: Colors.green,
|
||||
),
|
||||
home: SpeedometerScreen(isOverlayMode: isOverlayMode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeedometerScreen extends StatefulWidget {
|
||||
final bool isOverlayMode;
|
||||
|
||||
const SpeedometerScreen({super.key, this.isOverlayMode = false});
|
||||
|
||||
@override
|
||||
State<SpeedometerScreen> createState() => _SpeedometerScreenState();
|
||||
}
|
||||
|
||||
class _SpeedometerScreenState extends State<SpeedometerScreen> {
|
||||
double _speed = 0.0;
|
||||
double _heading = -1.0;
|
||||
SpeedUnit _currentUnit = SpeedUnit.kmh;
|
||||
int _currentThemeIndex = 0;
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeGps();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initializeGps() async {
|
||||
try {
|
||||
final serviceEnabled = await GpsService.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
setState(() => _errorMessage = 'Location services disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionGranted = await GpsService.requestPermissions();
|
||||
if (!permissionGranted) {
|
||||
setState(() => _errorMessage = 'Location permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
_positionSubscription = GpsService.positionStream.listen(
|
||||
_onPositionUpdate,
|
||||
onError: (error) => setState(() => _errorMessage = 'GPS error: $error'),
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = 'Failed to initialize GPS');
|
||||
}
|
||||
}
|
||||
|
||||
void _onPositionUpdate(Position position) {
|
||||
setState(() {
|
||||
_speed = position.speed;
|
||||
_heading = position.heading;
|
||||
_errorMessage = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _cycleUnit() {
|
||||
setState(() {
|
||||
_currentUnit = _currentUnit.next;
|
||||
});
|
||||
}
|
||||
|
||||
void _cycleTheme() {
|
||||
setState(() {
|
||||
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displaySpeed = _currentUnit.convert(_speed);
|
||||
final speedText = displaySpeed.toStringAsFixed(0);
|
||||
final currentTheme = ColorThemes.getTheme(_currentThemeIndex);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: currentTheme.background,
|
||||
body: SafeArea(
|
||||
child: GestureDetector(
|
||||
onTap: _cycleTheme,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
children: [
|
||||
// Speed display - takes most space, tappable
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: GestureDetector(
|
||||
onTap: _cycleUnit,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_errorMessage.isNotEmpty)
|
||||
Text(
|
||||
_errorMessage,
|
||||
style: TextStyle(
|
||||
color: currentTheme.speedText,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
else ...[
|
||||
Text(
|
||||
speedText,
|
||||
style: TextStyle(
|
||||
fontSize: 120,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: currentTheme.speedText,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_currentUnit.label,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: currentTheme.unitText,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Compass display - compact
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.navigation,
|
||||
size: 32,
|
||||
color: currentTheme.headingText,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
GpsService.formatHeading(_heading),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: currentTheme.headingText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
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,20 @@
|
||||
enum SpeedUnit {
|
||||
kmh('km/h', 1.0),
|
||||
mph('mph', 0.621371),
|
||||
knots('kts', 0.539957);
|
||||
|
||||
const SpeedUnit(this.label, this.multiplier);
|
||||
|
||||
final String label;
|
||||
final double multiplier;
|
||||
|
||||
double convert(double speedMps) {
|
||||
return speedMps * 3.6 * multiplier;
|
||||
}
|
||||
|
||||
SpeedUnit get next {
|
||||
final values = SpeedUnit.values;
|
||||
final currentIndex = values.indexOf(this);
|
||||
return values[(currentIndex + 1) % values.length];
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user