From 8e612037d08b8d0d8b5622ec2b41509ea8cb993f Mon Sep 17 00:00:00 2001 From: josiadmin Date: Mon, 26 Jan 2026 16:12:38 +0100 Subject: [PATCH] add graph_view --- lib/controller/graph_controller.dart | 60 +++++ lib/controller/home_controller.dart | 5 + lib/helper/sample_bindings.dart | 2 + lib/helper/sample_routes.dart | 6 + lib/models/chart_model.dart | 62 +++++ lib/models/graph_model.dart | 0 lib/pages/graph_view.dart | 103 +++++++++ lib/pages/home_view.dart | 8 +- lib/widgets/chart_line_widget.dart | 328 +++++++++++++++++++++++++++ 9 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 lib/controller/graph_controller.dart create mode 100644 lib/models/chart_model.dart create mode 100644 lib/models/graph_model.dart create mode 100644 lib/pages/graph_view.dart create mode 100644 lib/widgets/chart_line_widget.dart diff --git a/lib/controller/graph_controller.dart b/lib/controller/graph_controller.dart new file mode 100644 index 0000000..c009675 --- /dev/null +++ b/lib/controller/graph_controller.dart @@ -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 = [].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; + 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; + } +} diff --git a/lib/controller/home_controller.dart b/lib/controller/home_controller.dart index c35ecc9..8e4aabd 100644 --- a/lib/controller/home_controller.dart +++ b/lib/controller/home_controller.dart @@ -4,6 +4,7 @@ import '../models/tank_model.dart'; import '../pages/detail_view.dart'; import '../pages/edit_view.dart'; import '../services/appwrite_service.dart'; +import '../pages/graph_view.dart'; class HomeController extends GetxController { final isLoading = false.obs; @@ -100,4 +101,8 @@ class HomeController extends GetxController { Future navigateToAddTankEntry() async { await Get.offAllNamed(EditPage.namedRoute); } + + void goToGraphPage() { + Get.toNamed(GraphPage.namedRoute, arguments: listTankModel); + } } diff --git a/lib/helper/sample_bindings.dart b/lib/helper/sample_bindings.dart index 91d8855..1c41969 100644 --- a/lib/helper/sample_bindings.dart +++ b/lib/helper/sample_bindings.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import '../controller/detail_controller.dart'; import '../controller/edit_controller.dart'; +import '../controller/graph_controller.dart'; import '../controller/home_controller.dart'; import '../controller/login_controller.dart'; import '../controller/signin_controller.dart'; @@ -17,6 +18,7 @@ class SampleBindings extends Bindings { Get.lazyPut(() => HomeController()); Get.lazyPut(() => DetailController()); Get.lazyPut(() => EditController()); + Get.lazyPut(() => GraphController()); } diff --git a/lib/helper/sample_routes.dart b/lib/helper/sample_routes.dart index 6231633..908e7cb 100644 --- a/lib/helper/sample_routes.dart +++ b/lib/helper/sample_routes.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import '../pages/detail_view.dart'; import '../pages/edit_view.dart'; +import '../pages/graph_view.dart'; import 'sample_bindings.dart'; import '../pages/home_view.dart'; import '../pages/signin_view.dart'; @@ -34,5 +35,10 @@ class SampleRouts { page: () => const EditPage(), binding: sampleBindings, ), + GetPage( + name: GraphPage.namedRoute, + page: () => const GraphPage(), + binding: sampleBindings, + ), ]; } diff --git a/lib/models/chart_model.dart b/lib/models/chart_model.dart new file mode 100644 index 0000000..2c4ddad --- /dev/null +++ b/lib/models/chart_model.dart @@ -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 dataPoints; + + ChartData({required this.dataPoints}); + + factory ChartData.fromTankList(List tankList) { + List 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); + } +} diff --git a/lib/models/graph_model.dart b/lib/models/graph_model.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/graph_view.dart b/lib/pages/graph_view.dart new file mode 100644 index 0000000..22f0ddb --- /dev/null +++ b/lib/pages/graph_view.dart @@ -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 { + 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), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/home_view.dart b/lib/pages/home_view.dart index e7e91e0..56ffc36 100644 --- a/lib/pages/home_view.dart +++ b/lib/pages/home_view.dart @@ -48,7 +48,13 @@ class HomePage extends GetView { IconButton( icon: const Icon(Icons.refresh), onPressed: () { - controller.onInit(); + homCtrl.onInit(); + }, + ), + IconButton( + icon: const Icon(Icons.collections), + onPressed: () { + homCtrl.goToGraphPage(); }, ), IconButton( diff --git a/lib/widgets/chart_line_widget.dart b/lib/widgets/chart_line_widget.dart new file mode 100644 index 0000000..b258186 --- /dev/null +++ b/lib/widgets/chart_line_widget.dart @@ -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; + } +}