refactor: layered architecture (core/data/ui separation) #1
@@ -0,0 +1,42 @@
|
||||
/// Domain model representing processed GPS data.
|
||||
///
|
||||
/// Immutable — use [copyWith] to create modified copies.
|
||||
class LocationData {
|
||||
final double speed; // Raw speed in m/s
|
||||
final double heading; // Raw heading in degrees
|
||||
final String displaySpeed; // Formatted speed text (with -- logic)
|
||||
final String displayHeading; // Formatted heading text
|
||||
final bool isSpeedValid; // True if from recent GPS
|
||||
final bool isHeadingValid; // True if from recent GPS
|
||||
final DateTime timestamp; // Timestamp for staleness detection
|
||||
|
||||
const LocationData({
|
||||
required this.speed,
|
||||
required this.heading,
|
||||
required this.displaySpeed,
|
||||
required this.displayHeading,
|
||||
required this.isSpeedValid,
|
||||
required this.isHeadingValid,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
LocationData copyWith({
|
||||
double? speed,
|
||||
double? heading,
|
||||
String? displaySpeed,
|
||||
String? displayHeading,
|
||||
bool? isSpeedValid,
|
||||
bool? isHeadingValid,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
return LocationData(
|
||||
speed: speed ?? this.speed,
|
||||
heading: heading ?? this.heading,
|
||||
displaySpeed: displaySpeed ?? this.displaySpeed,
|
||||
displayHeading: displayHeading ?? this.displayHeading,
|
||||
isSpeedValid: isSpeedValid ?? this.isSpeedValid,
|
||||
isHeadingValid: isHeadingValid ?? this.isHeadingValid,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../core/constants/gps_constants.dart';
|
||||
import '../../services/logger.dart';
|
||||
import '../models/location_data.dart';
|
||||
import '../services/gps_service.dart';
|
||||
|
||||
/// Repository that wraps GpsService and applies GPS business logic.
|
||||
///
|
||||
/// Single source of truth for location data. Handles:
|
||||
/// - GPS stream management (start/stop/restart)
|
||||
/// - Data processing (staleness, precision switching, validation)
|
||||
/// - Lifecycle management (app foreground/background, wake lock)
|
||||
class LocationRepository extends WidgetsBindingObserver {
|
||||
StreamSubscription<Position>? _gpsSubscription;
|
||||
final StreamController<LocationData> _dataController =
|
||||
StreamController<LocationData>.broadcast();
|
||||
|
||||
// Use late to defer DateTime.now() — const constructors can't call methods
|
||||
late LocationData _currentData = LocationData(
|
||||
speed: 0.0,
|
||||
heading: -1.0,
|
||||
displaySpeed: '--',
|
||||
displayHeading: 'N/A',
|
||||
isSpeedValid: false,
|
||||
isHeadingValid: false,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
Timer? _staleDataTimer;
|
||||
Timer? _backgroundGracePeriodTimer;
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Lifecycle and precision state
|
||||
LocationAccuracy _currentAccuracy = LocationAccuracy.high;
|
||||
bool _hasFirstFix = false;
|
||||
DateTime? _firstFixTime;
|
||||
bool _isAppInForeground = true;
|
||||
bool _isGpsActive = false;
|
||||
|
||||
// Public stream for UI components to subscribe to
|
||||
Stream<LocationData> get locationStream => _dataController.stream;
|
||||
|
||||
// Get current data synchronously
|
||||
LocationData get currentData => _currentData;
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isGpsActive => _isGpsActive;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Initialize GPS: check permissions, get first fix, start stream.
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) {
|
||||
Logger.warn('Already initialized, skipping...', 'LocationRepository');
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info('Starting location repository initialization...', 'LocationRepository');
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Check GPS permissions and services first
|
||||
final serviceEnabled = await GpsService.isLocationServiceEnabled();
|
||||
Logger.debug('Location service enabled: $serviceEnabled', 'LocationRepository');
|
||||
if (!serviceEnabled) {
|
||||
Logger.error('GPS service disabled', 'LocationRepository');
|
||||
_updateData(_currentData.copyWith(
|
||||
displaySpeed: 'GPS OFF',
|
||||
displayHeading: 'GPS OFF',
|
||||
));
|
||||
_isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final permissionGranted = await GpsService.requestPermissions();
|
||||
Logger.debug('GPS permission granted: $permissionGranted', 'LocationRepository');
|
||||
if (!permissionGranted) {
|
||||
Logger.error('GPS permission denied', 'LocationRepository');
|
||||
_updateData(_currentData.copyWith(
|
||||
displaySpeed: 'NO PERM',
|
||||
displayHeading: 'NO PERM',
|
||||
));
|
||||
_isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get initial position with long timeout for cold start
|
||||
final initialPosition = await GpsService.getCurrentPosition(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT,
|
||||
);
|
||||
|
||||
if (initialPosition == null) {
|
||||
Logger.error('Initial GPS fix failed — null position', 'LocationRepository');
|
||||
_updateData(_currentData.copyWith(
|
||||
displaySpeed: 'GPS ERR',
|
||||
displayHeading: 'GPS ERR',
|
||||
));
|
||||
_isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info('Initial GPS fix obtained', 'LocationRepository');
|
||||
_hasFirstFix = true;
|
||||
_firstFixTime = DateTime.now();
|
||||
_onPositionUpdate(initialPosition);
|
||||
|
||||
// Start position stream
|
||||
Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'LocationRepository');
|
||||
_startPositionStream(_currentAccuracy);
|
||||
|
||||
_isGpsActive = true;
|
||||
await GpsService.setWakeLock(true);
|
||||
_isInitialized = true;
|
||||
} catch (e, stackTrace) {
|
||||
developer.log('GPS subscription creation failed: $e',
|
||||
error: e, stackTrace: stackTrace, name: 'LocationRepository');
|
||||
_updateData(_currentData.copyWith(
|
||||
displaySpeed: 'GPS ERR',
|
||||
displayHeading: 'GPS ERR',
|
||||
));
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Logger.info('Location repository initialized successfully', 'LocationRepository');
|
||||
}
|
||||
|
||||
void _startPositionStream(LocationAccuracy accuracy) {
|
||||
_gpsSubscription = GpsService.createPositionStream(accuracy: accuracy).listen(
|
||||
(Position position) {
|
||||
Logger.debug('GPS callback triggered', 'LocationRepository');
|
||||
_onPositionUpdate(position);
|
||||
},
|
||||
onError: (error) {
|
||||
Logger.error('GPS stream error callback: $error', 'LocationRepository');
|
||||
_onGpsError(error);
|
||||
},
|
||||
onDone: () {
|
||||
Logger.warn('GPS stream done callback - stream ended', 'LocationRepository');
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _onPositionUpdate(Position position) {
|
||||
Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'LocationRepository');
|
||||
|
||||
_scheduleStaleDataTimer();
|
||||
|
||||
// Validate speed
|
||||
final rawSpeed = position.speed;
|
||||
final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0;
|
||||
final double speed = speedValid ? rawSpeed : 0.0;
|
||||
|
||||
// Validate heading
|
||||
final rawHeading = position.heading;
|
||||
final bool headingValid = rawHeading.isFinite &&
|
||||
rawHeading >= GpsConfig.VALID_HEADING_MIN &&
|
||||
rawHeading < GpsConfig.VALID_HEADING_MAX;
|
||||
final double heading = headingValid ? rawHeading : _currentData.heading;
|
||||
|
||||
// Format display
|
||||
final displaySpeed = speed.toStringAsFixed(1);
|
||||
final displayHeading = headingValid
|
||||
? GpsService.formatHeading(heading)
|
||||
: _currentData.displayHeading;
|
||||
|
||||
Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'LocationRepository');
|
||||
|
||||
final locationData = LocationData(
|
||||
speed: speed,
|
||||
heading: heading,
|
||||
displaySpeed: displaySpeed,
|
||||
displayHeading: displayHeading,
|
||||
isSpeedValid: speedValid,
|
||||
isHeadingValid: headingValid,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
_updateData(locationData);
|
||||
|
||||
// Check precision switching after first-fix gate
|
||||
if (_hasFirstFix && _firstFixTime != null) {
|
||||
final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!);
|
||||
if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) {
|
||||
_checkPrecisionSwitch(position.speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onGpsError(dynamic error) {
|
||||
if (_isDisposed) return;
|
||||
final errorString = error.toString().toLowerCase();
|
||||
if (!errorString.contains('timeout') && !errorString.contains('temporarily')) {
|
||||
_updateData(_currentData.copyWith(
|
||||
displaySpeed: 'GPS ERR',
|
||||
displayHeading: 'GPS ERR',
|
||||
isSpeedValid: false,
|
||||
isHeadingValid: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleStaleDataTimer() {
|
||||
_staleDataTimer?.cancel();
|
||||
_staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout);
|
||||
}
|
||||
|
||||
void _handleStaleDataTimeout() {
|
||||
if (_isDisposed) return;
|
||||
final displayText = _currentData.displaySpeed;
|
||||
if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') {
|
||||
return;
|
||||
}
|
||||
|
||||
final headingStillValid = _currentData.isHeadingValid &&
|
||||
_currentData.heading >= GpsConfig.VALID_HEADING_MIN &&
|
||||
_currentData.heading < GpsConfig.VALID_HEADING_MAX;
|
||||
final displayHeading = headingStillValid ? _currentData.displayHeading : '--';
|
||||
|
||||
Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'LocationRepository');
|
||||
|
||||
final staleData = _currentData.copyWith(
|
||||
displaySpeed: '--',
|
||||
displayHeading: displayHeading,
|
||||
isSpeedValid: false,
|
||||
isHeadingValid: headingStillValid,
|
||||
);
|
||||
|
||||
_staleDataTimer = null;
|
||||
_updateData(staleData);
|
||||
}
|
||||
|
||||
void _updateData(LocationData newData) {
|
||||
if (_isDisposed) return;
|
||||
_currentData = newData;
|
||||
_dataController.add(newData);
|
||||
Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository');
|
||||
}
|
||||
|
||||
/// Check if GPS precision should be switched based on speed.
|
||||
void _checkPrecisionSwitch(double speedMps) {
|
||||
if (_currentAccuracy == LocationAccuracy.high) {
|
||||
if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) {
|
||||
_switchPrecision(LocationAccuracy.medium);
|
||||
}
|
||||
} else if (_currentAccuracy == LocationAccuracy.medium) {
|
||||
if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) {
|
||||
_switchPrecision(LocationAccuracy.high);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch GPS precision and restart stream with new accuracy.
|
||||
void _switchPrecision(LocationAccuracy newAccuracy) {
|
||||
if (_currentAccuracy == newAccuracy) return;
|
||||
|
||||
final speedKmh = _currentData.speed * 3.6;
|
||||
Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'LocationRepository');
|
||||
|
||||
_currentAccuracy = newAccuracy;
|
||||
_gpsSubscription?.cancel();
|
||||
_startPositionStream(newAccuracy);
|
||||
}
|
||||
|
||||
/// Handle app lifecycle state changes.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused) {
|
||||
_isAppInForeground = false;
|
||||
// No overlay logic here — overlay is deferred
|
||||
_updateWakeLock();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_isAppInForeground = true;
|
||||
_backgroundGracePeriodTimer?.cancel();
|
||||
_backgroundGracePeriodTimer = null;
|
||||
|
||||
// Restart GPS if it was stopped
|
||||
if (_gpsSubscription == null && _isInitialized) {
|
||||
restartGps();
|
||||
}
|
||||
|
||||
_updateWakeLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop GPS tracking.
|
||||
void stopGps() {
|
||||
_gpsSubscription?.cancel();
|
||||
_gpsSubscription = null;
|
||||
_isGpsActive = false;
|
||||
_staleDataTimer?.cancel();
|
||||
_updateWakeLock();
|
||||
Logger.info('GPS stopped', 'LocationRepository');
|
||||
}
|
||||
|
||||
/// Restart GPS tracking.
|
||||
void restartGps() {
|
||||
Logger.info('Restarting GPS', 'LocationRepository');
|
||||
_startPositionStream(_currentAccuracy);
|
||||
_isGpsActive = true;
|
||||
_updateWakeLock();
|
||||
}
|
||||
|
||||
/// Update wake lock based on GPS state and app visibility.
|
||||
Future<void> _updateWakeLock() async {
|
||||
final shouldKeepAwake = _isGpsActive && _isAppInForeground;
|
||||
await GpsService.setWakeLock(shouldKeepAwake);
|
||||
Logger.debug(
|
||||
'Wake lock ${shouldKeepAwake ? "enabled" : "disabled"} (GPS: $_isGpsActive, foreground: $_isAppInForeground)',
|
||||
'LocationRepository',
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
Logger.info('Disposing location repository...', 'LocationRepository');
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_backgroundGracePeriodTimer?.cancel();
|
||||
_backgroundGracePeriodTimer = null;
|
||||
_gpsSubscription?.cancel();
|
||||
_gpsSubscription = null;
|
||||
_staleDataTimer?.cancel();
|
||||
_staleDataTimer = null;
|
||||
_dataController.close();
|
||||
GpsService.setWakeLock(false);
|
||||
Logger.info('Location repository disposed', 'LocationRepository');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:async';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import '../../core/constants/gps_constants.dart';
|
||||
|
||||
/// Lightweight stateless wrapper around Geolocator.
|
||||
///
|
||||
/// Only handles platform I/O — no business logic.
|
||||
class GpsService {
|
||||
static double? _lastValidHeading;
|
||||
|
||||
/// Create a position stream with dynamic accuracy setting.
|
||||
static Stream<Position> createPositionStream({
|
||||
LocationAccuracy accuracy = LocationAccuracy.high,
|
||||
}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: accuracy,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> requestPermissions() async {
|
||||
final permission = await Permission.locationWhenInUse.request();
|
||||
return permission.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> isLocationServiceEnabled() async {
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
}
|
||||
|
||||
static Future<Position?> getCurrentPosition({
|
||||
LocationAccuracy accuracy = LocationAccuracy.bestForNavigation,
|
||||
Duration timeLimit = const Duration(seconds: 60),
|
||||
}) async {
|
||||
try {
|
||||
return await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: accuracy,
|
||||
timeLimit: timeLimit,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double getCachedHeading(double heading) {
|
||||
if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) {
|
||||
_lastValidHeading = heading;
|
||||
return heading;
|
||||
}
|
||||
return _lastValidHeading ?? 0.0;
|
||||
}
|
||||
|
||||
static String getCompassDirection(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((cachedHeading + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
static String formatHeading(double heading) {
|
||||
final cachedHeading = getCachedHeading(heading);
|
||||
return '${cachedHeading.round()} ${getCompassDirection(heading)}';
|
||||
}
|
||||
|
||||
static Future<void> setWakeLock(bool enable) async {
|
||||
await WakelockPlus.toggle(enable: enable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/constants/speed_unit.dart';
|
||||
import '../../../data/models/location_data.dart';
|
||||
import '../../../data/repositories/location_repository.dart';
|
||||
|
||||
/// ViewModel for the speedometer UI.
|
||||
///
|
||||
/// Thin state manager — subscribes to [LocationRepository] and exposes
|
||||
/// the current display state to the view. No data-layer logic here.
|
||||
class SpeedometerViewModel extends ChangeNotifier {
|
||||
final LocationRepository _repository;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
SpeedUnit _displayUnit = SpeedUnit.kmh;
|
||||
LocationData _displayData = LocationData(
|
||||
speed: 0.0,
|
||||
heading: -1.0,
|
||||
displaySpeed: '--',
|
||||
displayHeading: 'N/A',
|
||||
isSpeedValid: false,
|
||||
isHeadingValid: false,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
LocationData get displayData => _displayData;
|
||||
SpeedUnit get displayUnit => _displayUnit;
|
||||
|
||||
SpeedometerViewModel({
|
||||
required LocationRepository repository,
|
||||
required SharedPreferences prefs,
|
||||
}) : _repository = repository,
|
||||
_prefs = prefs;
|
||||
|
||||
void init() {
|
||||
_repository.locationStream.listen(_onLocationData);
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
void _onLocationData(LocationData data) {
|
||||
_displayData = data;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
_displayUnit = _parseSpeedUnit(_prefs.getString('speed_unit') ?? 'km/h');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static SpeedUnit _parseSpeedUnit(String unit) {
|
||||
switch (unit) {
|
||||
case 'mph':
|
||||
return SpeedUnit.mph;
|
||||
case 'kts':
|
||||
return SpeedUnit.knots;
|
||||
default:
|
||||
return SpeedUnit.kmh;
|
||||
}
|
||||
}
|
||||
|
||||
double get displaySpeed {
|
||||
return _displayUnit.convert(_displayData.speed);
|
||||
}
|
||||
|
||||
String get displaySpeedText {
|
||||
return _displayData.displaySpeed;
|
||||
}
|
||||
|
||||
String get displayHeading {
|
||||
return _displayData.displayHeading;
|
||||
}
|
||||
|
||||
bool get isSpeedValid {
|
||||
return _displayData.isSpeedValid;
|
||||
}
|
||||
|
||||
bool get isHeadingValid {
|
||||
return _displayData.isHeadingValid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/constants/overlay_constants.dart';
|
||||
import '../../../core/theme/color_themes.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
/// Shared speed display widget — integral + decimal parts with
|
||||
/// staleness dimming and tap-to-cycle-theme.
|
||||
///
|
||||
/// Dumb widget — accepts data as parameters so it works in both
|
||||
/// the old Provider-based and new ViewModel-based architectures.
|
||||
class SpeedDisplay extends StatelessWidget {
|
||||
final double fontSize;
|
||||
final double opacity;
|
||||
final String speedText;
|
||||
final DateTime timestamp;
|
||||
|
||||
const SpeedDisplay({
|
||||
super.key,
|
||||
required this.fontSize,
|
||||
required this.opacity,
|
||||
required this.speedText,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<SettingsProvider, ColorTheme>(
|
||||
selector: (_, s) => s.currentTheme,
|
||||
builder: (context, theme, _) {
|
||||
// Staleness — UI-layer concern
|
||||
final age = DateTime.now().difference(timestamp);
|
||||
final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD;
|
||||
final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD;
|
||||
final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0;
|
||||
final effectiveOpacity = opacity * staleOpacity;
|
||||
|
||||
final displayText = showDashes ? '--' : speedText;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<SettingsProvider>().cycleTheme();
|
||||
},
|
||||
child: _buildSpeedText(displayText, theme, fontSize, effectiveOpacity),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpeedText(String speedText, ColorTheme theme, double fontSize, double opacity) {
|
||||
if (speedText == '--') {
|
||||
return Text(
|
||||
speedText,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: theme.speedText.withValues(alpha: opacity),
|
||||
fontFamily: 'DIN1451Alt',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final parts = speedText.split('.');
|
||||
final integralPart = parts[0];
|
||||
final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0';
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
integralPart,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: theme.speedText.withValues(alpha: opacity),
|
||||
fontFamily: 'DIN1451Alt',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
decimalPart,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.5,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: theme.speedTextSub.withValues(alpha: opacity),
|
||||
fontFamily: 'DIN1451Alt',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user