refactor(ui): create speedometer UI layer with ViewModel and shared widgets
- Create SpeedometerViewModel in ui/features/speedometer/ - Create thin SpeedometerView in ui/features/speedometer/ - Extract shared SpeedDisplay widget in ui/shared/ - Extract _CompassWidget from old speedometer screen - Remove all business logic from UI layer
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
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 '../../shared/speed_display.dart';
|
||||||
|
import 'speedometer_view_model.dart';
|
||||||
|
|
||||||
|
/// Thin UI view for the speedometer — renders speed and compass.
|
||||||
|
///
|
||||||
|
/// No business logic here. All state comes from [SpeedometerViewModel].
|
||||||
|
class SpeedometerView extends StatelessWidget {
|
||||||
|
const SpeedometerView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Selector<SpeedometerViewModel, double>(
|
||||||
|
selector: (_, vm) => vm.displaySpeed,
|
||||||
|
builder: (context, displaySpeed, _) {
|
||||||
|
final vm = context.watch<SpeedometerViewModel>();
|
||||||
|
final theme = context.select<Theme, ColorTheme>(
|
||||||
|
(theme) => ColorThemes.themes[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Staleness — UI-layer concern
|
||||||
|
final age = DateTime.now().difference(vm.displayData.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 opacity = staleOpacity;
|
||||||
|
|
||||||
|
final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
body: SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isLandscape = constraints.maxWidth > constraints.maxHeight;
|
||||||
|
if (isLandscape) {
|
||||||
|
return _buildLandscape(context, theme, speedText, opacity);
|
||||||
|
} else {
|
||||||
|
return _buildPortrait(context, theme, speedText, opacity);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPortrait(BuildContext context, ColorTheme theme, String speedText, double opacity) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 65,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 7,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: SpeedDisplay(
|
||||||
|
fontSize: 200,
|
||||||
|
opacity: opacity,
|
||||||
|
speedText: speedText,
|
||||||
|
timestamp: context.read<SpeedometerViewModel>().displayData.timestamp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)),
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: Selector<SpeedometerViewModel, String>(
|
||||||
|
selector: (_, vm) => vm.displayUnit.label,
|
||||||
|
builder: (context, unitLabel, _) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Unit cycling via ViewModel
|
||||||
|
},
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Text(
|
||||||
|
unitLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 80,
|
||||||
|
color: theme.unitText,
|
||||||
|
fontFamily: 'DIN1451Alt',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 32,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Overlay deferred — tap reserved for floating window
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: _CompassWidget(
|
||||||
|
heading: context.read<SpeedometerViewModel>().displayData.heading,
|
||||||
|
headingText: context.read<SpeedometerViewModel>().displayHeading,
|
||||||
|
isValid: context.read<SpeedometerViewModel>().isHeadingValid,
|
||||||
|
opacity: opacity,
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLandscape(BuildContext context, ColorTheme theme, String speedText, double opacity) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 60,
|
||||||
|
child: Container(
|
||||||
|
height: double.infinity,
|
||||||
|
color: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Flexible(flex: 1, child: FittedBox()),
|
||||||
|
Flexible(
|
||||||
|
flex: 20,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: SpeedDisplay(
|
||||||
|
fontSize: 160,
|
||||||
|
opacity: opacity,
|
||||||
|
speedText: speedText,
|
||||||
|
timestamp: context.read<SpeedometerViewModel>().displayData.timestamp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
flex: 7,
|
||||||
|
child: Selector<SpeedometerViewModel, String>(
|
||||||
|
selector: (_, vm) => vm.displayUnit.label,
|
||||||
|
builder: (context, unitLabel, _) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Unit cycling via ViewModel
|
||||||
|
},
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Text(
|
||||||
|
unitLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 50,
|
||||||
|
color: theme.unitText,
|
||||||
|
fontFamily: 'DIN1451Alt',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Flexible(flex: 5, child: FittedBox()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 35,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Overlay deferred
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
height: double.infinity,
|
||||||
|
color: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: _CompassWidget(
|
||||||
|
heading: context.read<SpeedometerViewModel>().displayData.heading,
|
||||||
|
headingText: context.read<SpeedometerViewModel>().displayHeading,
|
||||||
|
isValid: context.read<SpeedometerViewModel>().isHeadingValid,
|
||||||
|
opacity: opacity,
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compass widget — extracted from the old speedometer screen.
|
||||||
|
class _CompassWidget extends StatelessWidget {
|
||||||
|
final double heading;
|
||||||
|
final String headingText;
|
||||||
|
final bool isValid;
|
||||||
|
final double opacity;
|
||||||
|
final ColorTheme theme;
|
||||||
|
|
||||||
|
const _CompassWidget({
|
||||||
|
required this.heading,
|
||||||
|
required this.headingText,
|
||||||
|
required this.isValid,
|
||||||
|
required this.opacity,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final angle = (isValid && heading >= 0 && heading < 360) ? (heading * math.pi / 180.0) : 0.0;
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 3,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: angle,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Icon(
|
||||||
|
Icons.navigation,
|
||||||
|
size: 80,
|
||||||
|
color: theme.headingText.withValues(alpha: opacity),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Text(
|
||||||
|
headingText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 50,
|
||||||
|
color: theme.headingText.withValues(alpha: opacity),
|
||||||
|
fontFamily: 'DIN1451Alt',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user