refactor: layered architecture (core/data/ui separation) #1
@@ -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