MotorSignal-App/lib/features/dashboard/dashboard_screen.dart

397 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/compass_service.dart';
import '../rides/ride_state_machine.dart';
import '../../models/telemetry_sample.dart';
import '../../services/telemetry_service.dart';
import 'dashboard_config.dart';
import 'widgets/g_force_visualizer.dart';
import 'widgets/lean_visualizer.dart';
import '../../services/alerts/road_alert_models.dart';
import '../../services/alerts/road_alert_service.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final telemetryAsync = ref.watch(telemetryStreamProvider);
final rideSession = ref.watch(rideStateMachineProvider);
final layoutMode = ref.watch(dashboardConfigProvider);
final alertsAsync = ref.watch(activeAlertsStreamProvider);
final activeAlerts = alertsAsync.value ?? [];
final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null;
TelemetrySample? telemetry = telemetryAsync.value;
double currentSpeedMph = telemetry?.speed ?? 0.0;
double heading = telemetry?.heading ?? 0.0;
double leanAngle = telemetry?.leanAngle ?? 0.0;
double accelG = telemetry?.longitudinalG ?? 0.0;
double lateralG = telemetry?.lateralG ?? 0.0;
return Theme(
data: ThemeData.dark().copyWith(
scaffoldBackgroundColor: Colors.black,
colorScheme: const ColorScheme.dark(
primary: Colors.cyanAccent,
secondary: Colors.amberAccent,
surface: Color(0xFF111111),
),
),
child: Scaffold(
body: SafeArea(
child: Column(
children: [
if (closestAlert != null) _buildAlertBanner(closestAlert),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
if (layoutMode == DashboardLayout.minimal) {
return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref);
}
if (layoutMode == DashboardLayout.touring) {
return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref);
}
if (isLandscape) {
return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref);
}
return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref);
},
),
),
],
),
),
),
);
}
Widget _buildAlertBanner(RoadAlert alert) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: alert.category.color.withValues(alpha: 0.2),
border: Border(bottom: BorderSide(color: alert.category.color, width: 2)),
),
child: Row(
children: [
Icon(alert.category.icon, color: alert.category.color, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alert.category.displayName.toUpperCase(),
style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.2),
),
Text(
'${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600),
),
],
),
),
],
),
);
}
Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTopBar(context, heading, session, ref),
const Spacer(flex: 1),
_buildCenterSpeedometer(context, speed),
const Spacer(flex: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: GForceVisualizer(accelG: accelG, lateralG: lateralG, session: session)),
Expanded(child: LeanVisualizer(lean: lean, session: session)),
],
),
const SizedBox(height: 24),
_buildTripStatsStrip(context, session),
const SizedBox(height: 24),
_buildActionButtons(context, session),
],
),
);
}
Widget _buildLandscapeLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
child: Row(
children: [
// Left Wing
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GForceVisualizer(accelG: accelG, lateralG: lateralG, session: session),
const SizedBox(height: 16),
_buildTripStatsStrip(context, session, isVertical: true),
],
),
),
// Center
Expanded(
flex: 3,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildTopBar(context, heading, session, ref),
_buildCenterSpeedometer(context, speed),
_buildActionButtons(context, session),
],
),
),
// Right Wing
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LeanVisualizer(lean: lean, session: session),
const SizedBox(height: 16),
_buildDataBox(context, 'STATUS', session.state.name.toUpperCase()),
],
),
),
],
),
);
}
Widget _buildTopBar(BuildContext context, double heading, RideSession session, WidgetRef ref) {
final headingStr = CompassService.getHeadingString(heading);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_buildLayoutSwitcher(ref),
const SizedBox(width: 8),
Text(
headingStr,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900, color: Colors.cyanAccent),
),
const SizedBox(width: 8),
Text(
'${heading.toStringAsFixed(0)}°',
style: const TextStyle(fontSize: 18, color: Colors.white70),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: session.state == RideState.riding ? Colors.red.withValues(alpha: 0.2) : Colors.white10,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: session.state == RideState.riding ? Colors.redAccent : Colors.white30,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (session.state == RideState.riding)
const Padding(
padding: EdgeInsets.only(right: 6.0),
child: Icon(Icons.circle, color: Colors.redAccent, size: 12),
),
Text(
session.state == RideState.riding ? 'REC' : session.state.name.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: session.state == RideState.riding ? Colors.redAccent : Colors.white,
),
),
],
),
),
],
);
}
Widget _buildCenterSpeedometer(BuildContext context, double speed) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
speed.toStringAsFixed(0),
style: const TextStyle(
fontSize: 140,
height: 1.0,
fontWeight: FontWeight.w900,
fontFamily: 'Roboto', // Ideally a monospaced/numeric font
color: Colors.white,
),
),
const Text(
'MPH',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 4.0,
color: Colors.cyanAccent,
),
),
],
);
}
Widget _buildTripStatsStrip(BuildContext context, RideSession session, {bool isVertical = false}) {
final children = [
_buildDataBox(context, 'TRIP', '${session.tripDistanceMiles.toStringAsFixed(2)} MI'),
_buildDataBox(context, 'TOP SPEED', '${session.topSpeedMph.toStringAsFixed(0)} MPH'),
_buildDataBox(context, 'AVG SPEED', '${session.averageSpeedMph.toStringAsFixed(1)} MPH'),
];
if (isVertical) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children.map((c) => Padding(padding: const EdgeInsets.only(bottom: 12), child: c)).toList(),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: children,
);
}
Widget _buildDataBox(BuildContext context, String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11, letterSpacing: 1.5)),
const SizedBox(height: 2),
Text(value, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600)),
],
);
}
Widget _buildActionButtons(BuildContext context, RideSession session) {
return Consumer(
builder: (context, ref, child) {
final notifier = ref.read(rideStateMachineProvider.notifier);
if (session.state == RideState.idle || session.state == RideState.finished) {
return SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent.withValues(alpha: 0.1),
foregroundColor: Colors.cyanAccent,
side: const BorderSide(color: Colors.cyanAccent, width: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => notifier.startRide(),
child: const Text('START SESSION', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 2)),
),
);
} else if (session.state == RideState.riding) {
return SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent.withValues(alpha: 0.1),
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => notifier.finishRide(),
child: const Text('END SESSION', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 2)),
),
);
}
return const SizedBox(height: 60);
},
);
}
Widget _buildLayoutSwitcher(WidgetRef ref) {
return PopupMenuButton<DashboardLayout>(
icon: const Icon(Icons.dashboard_customize, color: Colors.cyanAccent),
onSelected: (layout) {
ref.read(dashboardConfigProvider.notifier).setLayout(layout);
},
itemBuilder: (context) => [
const PopupMenuItem(value: DashboardLayout.sport, child: Text('Sport Layout')),
const PopupMenuItem(value: DashboardLayout.touring, child: Text('Touring Layout')),
const PopupMenuItem(value: DashboardLayout.minimal, child: Text('Minimal Layout')),
],
);
}
Widget _buildMinimalLayout(BuildContext context, double speed, {required RideSession session, required WidgetRef ref}) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildLayoutSwitcher(ref),
_buildDataBox(context, 'STATUS', session.state.name.toUpperCase()),
],
),
const Spacer(),
Text(
speed.toStringAsFixed(0),
style: const TextStyle(fontSize: 200, fontWeight: FontWeight.w900, height: 1.0, color: Colors.white),
),
const Text('MPH', style: TextStyle(fontSize: 40, fontWeight: FontWeight.w700, color: Colors.cyanAccent)),
const Spacer(),
_buildActionButtons(context, session),
],
),
);
}
Widget _buildTouringLayout(BuildContext context, double speed, double heading, RideSession session, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildTopBar(context, heading, session, ref),
const Spacer(),
_buildCenterSpeedometer(context, speed),
const Spacer(),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDataBox(context, 'TRIP (MI)', session.tripDistanceMiles.toStringAsFixed(1)),
_buildDataBox(context, 'AVG (MPH)', session.averageSpeedMph.toStringAsFixed(1)),
_buildDataBox(context, 'ELEV (M)', session.elevationGain.toStringAsFixed(0)),
_buildDataBox(context, 'MOVING', '${(session.movingTimeSeconds / 60).floor()}m'),
],
),
),
const SizedBox(height: 24),
_buildActionButtons(context, session),
],
),
);
}
}