397 lines
15 KiB
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|