Skip to content

Instantly share code, notes, and snippets.

@JohanScheepers
Last active April 8, 2025 20:37
Show Gist options
  • Save JohanScheepers/a8e8e071a1e3d6739ea219fc40ef8985 to your computer and use it in GitHub Desktop.
Save JohanScheepers/a8e8e071a1e3d6739ea219fc40ef8985 to your computer and use it in GitHub Desktop.
Dashboard Screen
// 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