From 3c9657132540309b68ad9e177d01c34fe4ec2a8d Mon Sep 17 00:00:00 2001 From: Irene Date: Sun, 23 Aug 2026 17:21:09 +0900 Subject: [PATCH] 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 --- .../speedometer/speedometer_view.dart | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 lib/ui/features/speedometer/speedometer_view.dart diff --git a/lib/ui/features/speedometer/speedometer_view.dart b/lib/ui/features/speedometer/speedometer_view.dart new file mode 100644 index 0000000..fae6233 --- /dev/null +++ b/lib/ui/features/speedometer/speedometer_view.dart @@ -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( + selector: (_, vm) => vm.displaySpeed, + builder: (context, displaySpeed, _) { + final vm = context.watch(); + final theme = context.select( + (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().displayData.timestamp, + ), + ), + ), + const Flexible(flex: 1, child: FittedBox(fit: BoxFit.contain)), + Flexible( + flex: 2, + child: Selector( + 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().displayData.heading, + headingText: context.read().displayHeading, + isValid: context.read().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().displayData.timestamp, + ), + ), + ), + Flexible( + flex: 7, + child: Selector( + 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().displayData.heading, + headingText: context.read().displayHeading, + isValid: context.read().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', + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file