Last active
April 8, 2025 20:37
-
-
Save JohanScheepers/a8e8e071a1e3d6739ea219fc40ef8985 to your computer and use it in GitHub Desktop.
Dashboard Screen
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Needs some more refinement | |
import 'package:flutter/material.dart'; | |
import 'dart:async'; | |
import 'dart:math' as math; // Keep dart:math import | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: 'Dashboard Concept', | |
theme: ThemeData( | |
brightness: Brightness.dark, // Use a dark theme base | |
primarySwatch: Colors.red, // Ferrari red accent | |
fontFamily: 'Roboto', // Standard Flutter font | |
), | |
home: const DashboardScreen(), | |
debugShowCheckedModeBanner: false, | |
); | |
} | |
} | |
// --- Dashboard Screen Widget --- | |
class DashboardScreen extends StatefulWidget { | |
const DashboardScreen({super.key}); | |
@override | |
State<DashboardScreen> createState() => _DashboardScreenState(); | |
} | |
class _DashboardScreenState extends State<DashboardScreen> { | |
double _rpm = 0.0; // 0 to 10 (representing 0 to 10000 RPM) | |
int _speed = 0; | |
String _gear = "N"; | |
Timer? _timer; | |
// Simulate data changes | |
@override | |
void initState() { | |
super.initState(); | |
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { | |
// Add check for mounted state before calling setState | |
if (!mounted) { | |
timer.cancel(); // Stop timer if widget is disposed | |
return; | |
} | |
setState(() { | |
// Simulate RPM fluctuation (simple sine wave for demo) | |
_rpm = 5.0 + | |
4.5 * | |
math.sin(DateTime.now().millisecondsSinceEpoch * | |
0.0015); // Slightly faster wave | |
if (_rpm < 0) _rpm = 0; | |
// Clamp RPM slightly below max for visual clarity of redline end | |
if (_rpm > 9.8) _rpm = 9.8; | |
// Simulate speed based on RPM (very basic) | |
_speed = (_rpm * 30).toInt() + 15; // Adjusted speed calculation | |
if (_speed < 0) _speed = 0; | |
if (_speed > 350) _speed = 350; // Cap speed (Ferrari-like) | |
// Simulate gear changes (more realistic thresholds) | |
if (_speed < 10 && _rpm < 1.2) { | |
_gear = "N"; | |
} else if (_speed < 40 && _rpm < 7.0) { | |
_gear = "1"; | |
} else if (_speed < 80 && _rpm < 7.5) { | |
_gear = "2"; | |
} else if (_speed < 130 && _rpm < 8.0) { | |
_gear = "3"; | |
} else if (_speed < 180 && _rpm < 8.5) { | |
_gear = "4"; | |
} else if (_speed < 240 && _rpm < 9.0) { | |
_gear = "5"; | |
} else if (_speed < 300 && _rpm < 9.5) { | |
_gear = "6"; | |
} else { | |
_gear = "7"; // Added 7th gear | |
} | |
// Ensure N if RPM is very low regardless of speed (e.g., coasting) | |
if (_rpm < 0.8 && _gear != "N") { | |
if (_speed < 5) _gear = "N"; | |
} | |
}); | |
}); | |
} | |
@override | |
void dispose() { | |
_timer?.cancel(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
backgroundColor: Colors.black, // Overall screen background remains black | |
body: SafeArea( | |
// Ensure content isn't under status bar/notches | |
child: Padding( | |
padding: const EdgeInsets.all(8.0), // Overall padding | |
child: Row( | |
crossAxisAlignment: | |
CrossAxisAlignment.stretch, // Make panels fill height | |
children: [ | |
// Left Info Panel | |
Expanded( | |
flex: 2, // Adjust flex ratio as needed | |
child: LeftPanel(rpm: _rpm, speed: _speed), | |
), | |
const SizedBox(width: 8), | |
// Central Tachometer | |
Expanded( | |
flex: 3, // Make tachometer larger | |
child: Tachometer(rpm: _rpm, gear: _gear), | |
), | |
const SizedBox(width: 8), | |
// Right Info Panel | |
Expanded( | |
flex: 2, // Adjust flex ratio as needed | |
child: RightPanel(), | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
} | |
// --- LeftPanel Widget --- | |
class LeftPanel extends StatefulWidget { | |
const LeftPanel({required this.rpm, required this.speed, super.key}); | |
final double rpm; | |
final int speed; | |
@override | |
State<LeftPanel> createState() => _LeftPanelState(); | |
} | |
class _LeftPanelState extends State<LeftPanel> { | |
final panelColor = Colors.grey[900]!.withAlpha(150); | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 12.0), | |
decoration: BoxDecoration( | |
color: panelColor, borderRadius: BorderRadius.circular(10)), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Column( | |
children: [ | |
Text(widget.speed.toString(), | |
style: const TextStyle( | |
color: Colors.white, | |
fontSize: 64, | |
fontWeight: FontWeight.bold, | |
fontFamily: 'Roboto', | |
letterSpacing: 1.5)), | |
const Text("km/h", | |
style: TextStyle( | |
color: Colors.grey, | |
fontSize: 20, | |
fontFamily: 'Roboto', | |
fontWeight: FontWeight.w500)), | |
], | |
), | |
const Spacer(), | |
InfoRow( | |
icon: Icons.thermostat_outlined, | |
text: "90°C", | |
valueColor: Colors.orangeAccent), | |
const SizedBox(height: 12), | |
InfoRow( | |
icon: Icons.water_drop_outlined, | |
text: "10.1 bar", | |
valueColor: Colors.lightBlueAccent), | |
const SizedBox(height: 12), | |
InfoRow( | |
icon: Icons.local_gas_station_outlined, | |
text: "${(widget.rpm * 4 + 20).clamp(5, 95).toInt()}%", | |
valueColor: Colors.greenAccent), | |
const SizedBox(height: 10), | |
], | |
), | |
); | |
} | |
} | |
// --- InfoRow Widget --- | |
class InfoRow extends StatefulWidget { | |
const InfoRow( | |
{required this.icon, | |
required this.text, | |
required this.valueColor, | |
super.key}); | |
final IconData icon; | |
final String text; | |
final Color valueColor; | |
@override | |
State<InfoRow> createState() => _InfoRowState(); | |
} | |
class _InfoRowState extends State<InfoRow> { | |
@override | |
Widget build(BuildContext context) { | |
return Row( | |
mainAxisAlignment: MainAxisAlignment.start, | |
children: [ | |
Icon(widget.icon, color: Colors.grey[400], size: 26), | |
const SizedBox(width: 10), | |
Text(widget.text, | |
style: TextStyle( | |
color: widget.valueColor, | |
fontSize: 20, | |
fontWeight: FontWeight.w600, | |
fontFamily: 'Roboto')), | |
], | |
); | |
} | |
} | |
// --- Tachometer Widget --- | |
class Tachometer extends StatefulWidget { | |
const Tachometer({required this.rpm, required this.gear, super.key}); | |
final double rpm; | |
final String gear; | |
final double maxRpm = 10.0; // Max value on the gauge (x1000 RPM) | |
final double redlineRpmStart = 9.0; // Define redline start value clearly | |
@override | |
State<Tachometer> createState() => _TachometerState(); | |
} | |
class _TachometerState extends State<Tachometer> { | |
final double maxRpm = 10.0; // Max value on the gauge (x1000 RPM) | |
final double redlineRpmStart = 9.0; // Define redline start value clearly | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
decoration: const BoxDecoration( | |
color: Colors.black, | |
), | |
child: AspectRatio( | |
aspectRatio: 1.0, | |
child: Stack( | |
alignment: Alignment.center, | |
children: [ | |
SizedBox( | |
width: double.infinity, | |
height: double.infinity, | |
child: CustomPaint( | |
painter: TachometerPainter( | |
rpm: widget.rpm, | |
maxRpm: maxRpm, | |
scaleColor: Colors.yellowAccent[700]!, | |
needleColor: Colors.redAccent[400]!, | |
redlineStart: | |
redlineRpmStart, // Pass the defined redline value | |
), | |
), | |
), | |
GearDisplay(gear: widget.gear), | |
], | |
), | |
), | |
); | |
} | |
} | |
// --- Tachometer Painter Class (Updated for Red Numbers) --- | |
class TachometerPainter extends CustomPainter { | |
final double rpm; | |
final double maxRpm; | |
final Color scaleColor; // Yellow part | |
final Color needleColor; // Red needle and redline | |
final double redlineStart; // The RPM value where redline begins (e.g., 9.0) | |
TachometerPainter({ | |
required this.rpm, | |
required this.maxRpm, | |
required this.scaleColor, | |
required this.needleColor, | |
required this.redlineStart, | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
final center = Offset(size.width / 2, size.height / 2); | |
final radius = math.min(size.width / 2, size.height / 2) * 0.90; | |
// --- Angle Setup (Clockwise) --- | |
const double startAngle = math.pi / 2; | |
const double sweepAngle = math.pi * 3 / 2; // POSITIVE for clockwise | |
// --- Paint Objects --- | |
final scalePaint = Paint() | |
..color = scaleColor | |
..style = PaintingStyle.stroke | |
..strokeWidth = 8.0 | |
..strokeCap = StrokeCap.round; | |
final redlinePaint = Paint() | |
..color = needleColor | |
..style = PaintingStyle.stroke | |
..strokeWidth = 8.0 | |
..strokeCap = StrokeCap.round; | |
final tickPaint = Paint() | |
..color = Colors.grey[400]! | |
..style = PaintingStyle.stroke | |
..strokeWidth = 1.5; | |
final majorTickPaint = Paint() | |
..color = Colors.grey[200]! | |
..style = PaintingStyle.stroke | |
..strokeWidth = 3.0; | |
final needlePaint = Paint() | |
..color = needleColor | |
..style = PaintingStyle.fill; | |
final needleBasePaint = Paint() | |
..color = Colors.grey[850]! | |
..style = PaintingStyle.fill; | |
final backgroundPaint = Paint()..color = Colors.black; | |
// --- Drawing Order --- | |
// 1. Draw Background | |
canvas.drawCircle(center, radius, backgroundPaint); | |
// 2. Draw Scale Arcs | |
final double redlineStartRatio = redlineStart / maxRpm; | |
final double nonRedlineSweepAngle = sweepAngle * redlineStartRatio; | |
final double redlineSweepAngle = sweepAngle * (1.0 - redlineStartRatio); | |
final double redlineStartAngle = startAngle + nonRedlineSweepAngle; | |
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, | |
nonRedlineSweepAngle, false, scalePaint); | |
if (redlineSweepAngle.abs() > 0.01) { | |
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), | |
redlineStartAngle, redlineSweepAngle, false, redlinePaint); | |
} | |
// 3. Draw Ticks and Numbers | |
const int numberOfMajorTicks = 10; | |
const int minorTicksPerMajor = 4; | |
final int totalTickSegments = numberOfMajorTicks * (minorTicksPerMajor + 1); | |
final double tickSweep = sweepAngle / totalTickSegments; | |
for (int i = 0; i <= totalTickSegments; i++) { | |
final currentAngle = startAngle + i * tickSweep; | |
final bool isMajorTick = i % (minorTicksPerMajor + 1) == 0; | |
final tickStartRadius = radius * (isMajorTick ? 0.90 : 0.94); | |
final tickEndRadius = radius * 0.99; | |
final tickStart = center + | |
Offset(math.cos(currentAngle) * tickStartRadius, | |
math.sin(currentAngle) * tickStartRadius); | |
final tickEnd = center + | |
Offset(math.cos(currentAngle) * tickEndRadius, | |
math.sin(currentAngle) * tickEndRadius); | |
canvas.drawLine( | |
tickStart, tickEnd, isMajorTick ? majorTickPaint : tickPaint); | |
// Draw Numbers for Major Ticks | |
if (isMajorTick) { | |
final number = (i ~/ (minorTicksPerMajor + 1)); | |
final numberRadius = radius * 0.82; | |
final numberAngle = currentAngle; | |
// ---- COLOR CHANGE LOGIC FOR NUMBERS ---- | |
final bool isRedlineNumber = | |
number >= redlineStart; // Check if number is in redline zone | |
final Color numberColor = isRedlineNumber | |
? needleColor | |
: Colors.grey[200]!; // Use red or grey | |
final textPainter = TextPainter( | |
text: TextSpan( | |
text: number.toString(), | |
style: TextStyle( | |
color: numberColor, // Apply the determined color | |
fontSize: 18.0, | |
fontWeight: FontWeight.bold, | |
fontFamily: 'Roboto', | |
), | |
), | |
textDirection: TextDirection.ltr, | |
); | |
textPainter.layout(); | |
final numberPos = center + | |
Offset(math.cos(numberAngle) * numberRadius, | |
math.sin(numberAngle) * numberRadius); | |
final textOffset = | |
Offset(-textPainter.width / 2, -textPainter.height / 2); | |
canvas.save(); | |
canvas.translate(numberPos.dx, numberPos.dy); | |
textPainter.paint(canvas, textOffset); | |
canvas.restore(); | |
} | |
} | |
// 4. Draw Needle | |
final double rpmRatio = (rpm / maxRpm).clamp(0.0, 1.0); | |
final double needleAngle = startAngle + rpmRatio * sweepAngle; | |
final needleLength = radius * 0.92; | |
final needleBaseWidth = 10.0; | |
final pivotOffset = 12.0; | |
final pivotPoint = center + | |
Offset(math.cos(needleAngle + math.pi) * pivotOffset, | |
math.sin(needleAngle + math.pi) * pivotOffset); | |
final tip = center + | |
Offset(math.cos(needleAngle) * needleLength, | |
math.sin(needleAngle) * needleLength); | |
final baseAngle1 = needleAngle + math.pi / 2; | |
final baseAngle2 = needleAngle - math.pi / 2; | |
final base1 = pivotPoint + | |
Offset(math.cos(baseAngle1) * needleBaseWidth / 2, | |
math.sin(baseAngle1) * needleBaseWidth / 2); | |
final base2 = pivotPoint + | |
Offset(math.cos(baseAngle2) * needleBaseWidth / 2, | |
math.sin(baseAngle2) * needleBaseWidth / 2); | |
final Path needlePath = Path() | |
..moveTo(tip.dx, tip.dy) | |
..lineTo(base1.dx, base1.dy) | |
..lineTo(base2.dx, base2.dy) | |
..close(); | |
canvas.drawPath(needlePath, needlePaint); | |
// 5. Draw Needle Hub | |
canvas.drawCircle(center, 14.0, needleBasePaint); | |
canvas.drawCircle(center, 6.0, needlePaint); | |
} | |
@override | |
bool shouldRepaint(covariant TachometerPainter oldDelegate) { | |
return oldDelegate.rpm != rpm || | |
oldDelegate.scaleColor != scaleColor || | |
oldDelegate.needleColor != needleColor || | |
oldDelegate.redlineStart != redlineStart; | |
} | |
} | |
// --- GearDisplay Widget --- | |
class GearDisplay extends StatefulWidget { | |
const GearDisplay({required this.gear, super.key}); | |
final String gear; | |
@override | |
State<GearDisplay> createState() => _GearDisplayState(); | |
} | |
class _GearDisplayState extends State<GearDisplay> { | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
width: 90, | |
height: 90, | |
decoration: BoxDecoration( | |
color: Colors.black.withAlpha(210), | |
shape: BoxShape.circle, | |
border: Border.all(color: Colors.grey[800]!, width: 2.5)), | |
child: Center( | |
child: Text( | |
widget.gear, | |
style: TextStyle( | |
color: widget.gear == "N" ? Colors.yellowAccent[700] : Colors.white, | |
fontSize: 52, | |
fontWeight: FontWeight.w900, | |
fontFamily: 'Roboto', | |
fontStyle: FontStyle.italic, | |
), | |
), | |
), | |
); | |
} | |
} | |
// --- RightPanel Widget--- | |
class RightPanel extends StatefulWidget { | |
const RightPanel({super.key}); | |
@override | |
State<RightPanel> createState() => _RightPanelState(); | |
} | |
class _RightPanelState extends State<RightPanel> { | |
final panelColor = Colors.grey[900]!.withAlpha(150); | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 12.0), | |
decoration: BoxDecoration( | |
color: panelColor, borderRadius: BorderRadius.circular(10)), | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
InfoRow( | |
icon: Icons.settings_suggest_outlined, | |
text: "RACE", | |
valueColor: Colors.white), | |
const SizedBox(height: 12), | |
InfoRow( | |
icon: Icons.timer_outlined, | |
text: "1:28.13", | |
valueColor: Colors.cyanAccent), | |
const SizedBox(height: 12), | |
InfoRow( | |
icon: Icons.battery_charging_full_outlined, | |
text: "92%", | |
valueColor: Colors.lightBlueAccent[100]!), | |
const Spacer(), | |
Row( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
children: [ | |
Icon(Icons.warning_amber_rounded, | |
color: Colors.yellowAccent[700], size: 28), | |
Icon(Icons.remove_circle_outline, | |
color: Colors.orangeAccent, size: 28), | |
Icon(Icons.check_circle_outline, | |
color: Colors.greenAccent, size: 28), | |
], | |
), | |
const SizedBox(height: 5), | |
], | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment