add graph_view
This commit is contained in:
60
lib/controller/graph_controller.dart
Normal file
60
lib/controller/graph_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> navigateToAddTankEntry() async {
|
||||
await Get.offAllNamed(EditPage.namedRoute);
|
||||
}
|
||||
|
||||
void goToGraphPage() {
|
||||
Get.toNamed(GraphPage.namedRoute, arguments: listTankModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(() => HomeController());
|
||||
Get.lazyPut<DetailController>(() => DetailController());
|
||||
Get.lazyPut<EditController>(() => EditController());
|
||||
Get.lazyPut<GraphController>(() => GraphController());
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
62
lib/models/chart_model.dart
Normal file
62
lib/models/chart_model.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
0
lib/models/graph_model.dart
Normal file
0
lib/models/graph_model.dart
Normal file
103
lib/pages/graph_view.dart
Normal file
103
lib/pages/graph_view.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,13 @@ class HomePage extends GetView<HomeController> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
controller.onInit();
|
||||
homCtrl.onInit();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.collections),
|
||||
onPressed: () {
|
||||
homCtrl.goToGraphPage();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
|
||||
328
lib/widgets/chart_line_widget.dart
Normal file
328
lib/widgets/chart_line_widget.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user