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:
2026-08-23 17:21:09 +09:00
parent 44480eb969
commit 3c96571325
@@ -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',
),
),
),
),
],
);
}
}