add graph_view

This commit is contained in:
2026-01-26 16:12:38 +01:00
parent 78e27ac8d0
commit 8e612037d0
9 changed files with 573 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
import 'package:flutter_tank_web_app/models/tank_model.dart';
import 'package:get/get.dart';
class GraphController extends GetxController {
final listTankModel = <TankModel>[].obs;
final sumYearKm = 0.0.obs;
final sumYearLiters = 0.0.obs;
final sumYearPrice = 0.0.obs;
final averagePricePerLiter = 0.0.obs;
@override
void onInit() {
if (Get.arguments != null) {
var args = Get.arguments as List<TankModel>;
listTankModel.addAll(args);
_calcSumms();
}
super.onInit();
}
@override
void onReady() {}
@override
void onClose() {}
// Calculate yearly summaries
void _calcSumms() {
if (listTankModel.length < 2) {
sumYearKm.value = 0.0;
sumYearLiters.value = 0.0;
sumYearPrice.value = 0.0;
return;
}
// Sort by date to calculate km correctly
listTankModel.sort((a, b) {
var dateA = DateTime.tryParse(a.szDate)!;
var dateB = DateTime.tryParse(b.szDate)!;
return dateA.compareTo(dateB);
});
var firstOdometer = double.tryParse(listTankModel.first.szOdometer) ?? 0.0;
var lastOdometer = double.tryParse(listTankModel.last.szOdometer) ?? 0.0;
sumYearKm.value = lastOdometer - firstOdometer;
double totalLiters = 0.0;
double totalPrice = 0.0;
for (var tank in listTankModel) {
totalLiters += double.tryParse(tank.szLiters) ?? 0.0;
totalPrice += double.tryParse(tank.szPriceTotal) ?? 0.0;
}
sumYearLiters.value = totalLiters;
sumYearPrice.value = totalPrice;
averagePricePerLiter.value = totalPrice / totalLiters;
}
}

View File

@@ -4,6 +4,7 @@ import '../models/tank_model.dart';
import '../pages/detail_view.dart'; import '../pages/detail_view.dart';
import '../pages/edit_view.dart'; import '../pages/edit_view.dart';
import '../services/appwrite_service.dart'; import '../services/appwrite_service.dart';
import '../pages/graph_view.dart';
class HomeController extends GetxController { class HomeController extends GetxController {
final isLoading = false.obs; final isLoading = false.obs;
@@ -100,4 +101,8 @@ class HomeController extends GetxController {
Future<void> navigateToAddTankEntry() async { Future<void> navigateToAddTankEntry() async {
await Get.offAllNamed(EditPage.namedRoute); await Get.offAllNamed(EditPage.namedRoute);
} }
void goToGraphPage() {
Get.toNamed(GraphPage.namedRoute, arguments: listTankModel);
}
} }

View File

@@ -2,6 +2,7 @@ import 'package:get/get.dart';
import '../controller/detail_controller.dart'; import '../controller/detail_controller.dart';
import '../controller/edit_controller.dart'; import '../controller/edit_controller.dart';
import '../controller/graph_controller.dart';
import '../controller/home_controller.dart'; import '../controller/home_controller.dart';
import '../controller/login_controller.dart'; import '../controller/login_controller.dart';
import '../controller/signin_controller.dart'; import '../controller/signin_controller.dart';
@@ -17,6 +18,7 @@ class SampleBindings extends Bindings {
Get.lazyPut<HomeController>(() => HomeController()); Get.lazyPut<HomeController>(() => HomeController());
Get.lazyPut<DetailController>(() => DetailController()); Get.lazyPut<DetailController>(() => DetailController());
Get.lazyPut<EditController>(() => EditController()); Get.lazyPut<EditController>(() => EditController());
Get.lazyPut<GraphController>(() => GraphController());
} }

View File

@@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../pages/detail_view.dart'; import '../pages/detail_view.dart';
import '../pages/edit_view.dart'; import '../pages/edit_view.dart';
import '../pages/graph_view.dart';
import 'sample_bindings.dart'; import 'sample_bindings.dart';
import '../pages/home_view.dart'; import '../pages/home_view.dart';
import '../pages/signin_view.dart'; import '../pages/signin_view.dart';
@@ -34,5 +35,10 @@ class SampleRouts {
page: () => const EditPage(), page: () => const EditPage(),
binding: sampleBindings, binding: sampleBindings,
), ),
GetPage(
name: GraphPage.namedRoute,
page: () => const GraphPage(),
binding: sampleBindings,
),
]; ];
} }

View File

@@ -0,0 +1,62 @@
class ChartDataPoint {
final int stopNumber;
final double litersPerStop;
final double pricePerStop;
final double pricePerLiter;
ChartDataPoint({
required this.stopNumber,
required this.litersPerStop,
required this.pricePerStop,
required this.pricePerLiter,
});
}
class ChartData {
final List<ChartDataPoint> dataPoints;
ChartData({required this.dataPoints});
factory ChartData.fromTankList(List<dynamic> tankList) {
List<ChartDataPoint> points = [];
for (int i = 0; i < tankList.length; i++) {
final tank = tankList[i];
final liters = double.tryParse(tank.szLiters) ?? 0.0;
final priceTotal = double.tryParse(tank.szPriceTotal) ?? 0.0;
final pricePerLiter = double.tryParse(tank.szPricePerLiter) ?? 0.0;
points.add(
ChartDataPoint(
stopNumber: i + 1,
litersPerStop: liters,
pricePerStop: priceTotal,
pricePerLiter: pricePerLiter,
),
);
}
return ChartData(dataPoints: points);
}
double getMaxLiters() {
if (dataPoints.isEmpty) return 0;
return dataPoints
.map((p) => p.litersPerStop)
.reduce((a, b) => a > b ? a : b);
}
double getMaxPrice() {
if (dataPoints.isEmpty) return 0;
return dataPoints
.map((p) => p.pricePerStop)
.reduce((a, b) => a > b ? a : b);
}
double getMaxPricePerLiter() {
if (dataPoints.isEmpty) return 0;
return dataPoints
.map((p) => p.pricePerLiter)
.reduce((a, b) => a > b ? a : b);
}
}

View File

103
lib/pages/graph_view.dart Normal file
View File

@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/graph_controller.dart';
import '../models/chart_model.dart';
import '../widgets/detail_info_card_widget.dart';
import '../widgets/detail_stat_widget.dart';
import '../widgets/chart_line_widget.dart';
class GraphPage extends GetView<GraphController> {
static const String namedRoute = '/graph-page';
const GraphPage({super.key});
@override
Widget build(BuildContext context) {
var displayWidth = MediaQuery.of(context).size.width;
var displayHeight = MediaQuery.of(context).size.height;
var graphCtrl = controller;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
title: const Text('Graph Page'),
centerTitle: true,
),
body: Container(
width: displayWidth,
height: displayHeight,
padding: EdgeInsets.all(16.0),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.blueGrey[800]!,
Colors.blueGrey[600]!,
Colors.blueGrey[300]!,
Colors.blue[100]!,
],
),
),
child: Center(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
// Fahrzeugdaten
DetailInfoCardWidget(
title: 'Jahresstatistik',
children: [
DetailStatWidget(
icon: Icons.directions_car,
label: 'Jahreskilometerstand',
value: '${graphCtrl.sumYearKm.value} KM',
iconColor: Colors.blue,
valueSize: 24,
valueWeight: FontWeight.bold,
),
DetailStatWidget(
icon: Icons.gas_meter,
label: 'Jahresliterverbrauch',
value:
'${graphCtrl.sumYearLiters.value.toStringAsFixed(2)} L',
iconColor: Colors.blue,
valueSize: 24,
valueWeight: FontWeight.bold,
),
DetailStatWidget(
icon: Icons.euro,
label: 'Jahreskostenverbrauch',
value:
'${graphCtrl.sumYearPrice.value.toStringAsFixed(2)}',
iconColor: Colors.blue,
valueSize: 24,
valueWeight: FontWeight.bold,
),
DetailStatWidget(
icon: Icons.euro,
label: 'Jahresdurchschnittspreis pro Liter',
value:
'${graphCtrl.averagePricePerLiter.value.toStringAsFixed(2)}',
iconColor: Colors.blue,
valueSize: 24,
valueWeight: FontWeight.bold,
),
],
),
const SizedBox(height: 24),
// Liniendiagramm
ChartLineWidget(
chartData: ChartData.fromTankList(graphCtrl.listTankModel),
),
],
),
),
),
),
);
}
}

View File

@@ -48,7 +48,13 @@ class HomePage extends GetView<HomeController> {
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () { onPressed: () {
controller.onInit(); homCtrl.onInit();
},
),
IconButton(
icon: const Icon(Icons.collections),
onPressed: () {
homCtrl.goToGraphPage();
}, },
), ),
IconButton( IconButton(

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import '../models/chart_model.dart';
class ChartLineWidget extends StatelessWidget {
final ChartData chartData;
const ChartLineWidget({super.key, required this.chartData});
@override
Widget build(BuildContext context) {
if (chartData.dataPoints.isEmpty) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.blueGrey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blueGrey[200]!),
),
child: const Center(child: Text('Keine Daten verfügbar')),
);
}
final maxLiters = chartData.getMaxLiters();
final maxPrice = chartData.getMaxPrice();
final maxPricePerLiter = chartData.getMaxPricePerLiter();
final maxY = [
maxLiters,
maxPrice,
maxPricePerLiter,
].reduce((a, b) => a > b ? a : b);
final padding = maxY * 0.1;
final yAxisMax = maxY + padding;
return Column(
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.blueGrey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blueGrey[200]!),
),
child: Column(
children: [
SizedBox(
height: 300,
child: CustomPaint(
painter: LineChartPainter(
chartData: chartData,
yAxisMax: yAxisMax,
),
size: Size.infinite,
),
),
const SizedBox(height: 20),
Wrap(
spacing: 20,
runSpacing: 10,
children: [
_LegendItem(color: Colors.red, label: 'Liter pro Stop'),
_LegendItem(color: Colors.green, label: 'Preis pro Stop (€)'),
_LegendItem(
color: Colors.amber,
label: 'Preis pro Liter (€)',
),
],
),
],
),
),
],
);
}
}
class _LegendItem extends StatelessWidget {
final Color color;
final String label;
const _LegendItem({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.black87),
),
],
);
}
}
class LineChartPainter extends CustomPainter {
final ChartData chartData;
final double yAxisMax;
LineChartPainter({required this.chartData, required this.yAxisMax});
@override
void paint(Canvas canvas, Size size) {
if (chartData.dataPoints.isEmpty) return;
final paint = Paint()
..color = Colors.blueGrey[300]!
..strokeWidth = 1;
final axisPaint = Paint()
..color = Colors.blueGrey[600]!
..strokeWidth = 2;
final padding = EdgeInsets.fromLTRB(40, 20, 20, 40);
final graphWidth = size.width - padding.left - padding.right;
final graphHeight = size.height - padding.top - padding.bottom;
// Draw grid and axes
_drawAxes(canvas, size, padding, axisPaint, paint);
// Draw lines
_drawLine(
canvas,
chartData,
padding,
graphWidth,
graphHeight,
Colors.red,
(point) => point.litersPerStop,
);
_drawLine(
canvas,
chartData,
padding,
graphWidth,
graphHeight,
Colors.green,
(point) => point.pricePerStop,
);
_drawLine(
canvas,
chartData,
padding,
graphWidth,
graphHeight,
Colors.amber,
(point) => point.pricePerLiter,
);
// Draw points
final pointPaint = Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round;
final pointsSize = 4.0;
for (var point in chartData.dataPoints) {
final xPos =
padding.left +
(point.stopNumber - 1) *
(graphWidth /
(chartData.dataPoints.length - 1 > 0
? chartData.dataPoints.length - 1
: 1));
// Red points (Liters)
final yLiters =
padding.top +
graphHeight -
(point.litersPerStop / yAxisMax) * graphHeight;
pointPaint.color = Colors.red;
canvas.drawCircle(Offset(xPos, yLiters), pointsSize, pointPaint);
// Green points (Price)
final yPrice =
padding.top +
graphHeight -
(point.pricePerStop / yAxisMax) * graphHeight;
pointPaint.color = Colors.green;
canvas.drawCircle(Offset(xPos, yPrice), pointsSize, pointPaint);
// Amber points (Price per Liter)
final yPricePerLiter =
padding.top +
graphHeight -
(point.pricePerLiter / yAxisMax) * graphHeight;
pointPaint.color = Colors.amber;
canvas.drawCircle(Offset(xPos, yPricePerLiter), pointsSize, pointPaint);
}
}
void _drawAxes(
Canvas canvas,
Size size,
EdgeInsets padding,
Paint axisPaint,
Paint gridPaint,
) {
// Y-axis
canvas.drawLine(
Offset(padding.left, padding.top),
Offset(padding.left, size.height - padding.bottom),
axisPaint,
);
// X-axis
canvas.drawLine(
Offset(padding.left, size.height - padding.bottom),
Offset(size.width - padding.right, size.height - padding.bottom),
axisPaint,
);
final graphHeight = size.height - padding.top - padding.bottom;
final graphWidth = size.width - padding.left - padding.right;
// Y-axis labels
final steps = 5;
for (int i = 0; i <= steps; i++) {
final yPos = padding.top + (graphHeight / steps) * i;
final value = yAxisMax - (yAxisMax / steps) * i;
// Grid line
canvas.drawLine(
Offset(padding.left, yPos),
Offset(size.width - padding.right, yPos),
gridPaint..strokeWidth = 0.5,
);
// Label
final textPainter = TextPainter(
text: TextSpan(
text: value.toStringAsFixed(1),
style: const TextStyle(color: Colors.black54, fontSize: 10),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
padding.left - textPainter.width - 8,
yPos - textPainter.height / 2,
),
);
}
// X-axis labels
for (int i = 0; i < chartData.dataPoints.length; i++) {
final xPos =
padding.left +
i *
(graphWidth /
(chartData.dataPoints.length - 1 > 0
? chartData.dataPoints.length - 1
: 1));
final textPainter = TextPainter(
text: TextSpan(
text: (i + 1).toString(),
style: const TextStyle(color: Colors.black54, fontSize: 10),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(xPos - textPainter.width / 2, size.height - padding.bottom + 8),
);
}
}
void _drawLine(
Canvas canvas,
ChartData chartData,
EdgeInsets padding,
double graphWidth,
double graphHeight,
Color color,
double Function(ChartDataPoint) getValue,
) {
final paint = Paint()
..color = color
..strokeWidth = 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final points = chartData.dataPoints;
if (points.length < 2) return;
final path = Path();
for (int i = 0; i < points.length; i++) {
final x =
padding.left +
i * (graphWidth / (points.length - 1 > 0 ? points.length - 1 : 1));
final y =
padding.top +
graphHeight -
(getValue(points[i]) / yAxisMax) * graphHeight;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(LineChartPainter oldDelegate) {
return oldDelegate.chartData != chartData ||
oldDelegate.yAxisMax != yAxisMax;
}
}