price per item is printed

This commit is contained in:
atseirjo
2026-10-28 12:32:49 +01:00
parent c32adbad50
commit 77fbe16867
31 changed files with 656 additions and 55 deletions
+2
View File
@@ -46,3 +46,5 @@ app.*.map.json
# Widget Preview related # Widget Preview related
.widget_preview/ .widget_preview/
# Exclusive lock used by scripts/build_release.ps1.
.release-build.lock
+36
View File
@@ -2,6 +2,42 @@
A new Flutter project. A new Flutter project.
## Kleine Fenster
Windows und Linux verwenden eine minimale Inhaltsgröße von 320 × 480 logischen
Pixeln (ohne Fensterrahmen). In schmalen Ansichten stehen die Hauptbereiche
untereinander. Unter 320 Pixeln Browserbreite lässt sich die App horizontal
scrollen; die Seite und Formulareingaben bleiben beim Verkleinern erhalten.
## Release-Builds mit automatischer Versionierung
Im Projektordner mit PowerShell ausführen:
```powershell
.\build_release.cmd
```
Erstellt standardmäßig eine Android-APK unter
`build/app/outputs/flutter-apk/app-release.apk`. Vor dem Build werden Patchversion
und Buildnummer erhöht, z. B. `1.0.0+1` → `1.0.1+2`. Ein erfolgreicher Build behält
diese Version in `pubspec.yaml`; bei einem Fehler wird die bisherige Version
wiederhergestellt. Die installierte Version und Buildnummer stehen im Hamburger-Menü.
Weitere Ziele und zusätzliche Flutter-Argumente:
```powershell
.\build_release.cmd -Target windows
.\build_release.cmd -Target appbundle
.\build_release.cmd -FlutterArguments --split-per-abi
```
Für automatische Versionierung immer dieses Skript verwenden. Direkte Aufrufe
von `flutter build` und Debug-Builds erhöhen die Version nicht. Änderungen an
`pubspec.yaml` und gegebenenfalls `pubspec.lock` nach dem Release ins Repository übernehmen.
Der CMD-Starter erlaubt PowerShell-Skripte nur für diesen einen Prozess; die
systemweite Ausführungsrichtlinie bleibt unverändert.
## Getting Started ## Getting Started
This project is a starting point for a Flutter application. This project is a starting point for a Flutter application.
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_release.ps1" %*
exit /b %errorlevel%
+3
View File
@@ -77,6 +77,9 @@ class AnwendungController extends GetxController {
entry.toJson(), entry.toJson(),
]); ]);
anwendungen.assignAll([entry]); anwendungen.assignAll([entry]);
if (Get.isRegistered<HomeController>()) {
Get.find<HomeController>().loadProfile();
}
hasError.value = false; hasError.value = false;
message.value = 'Verarbeitungsprofil gespeichert.'; message.value = 'Verarbeitungsprofil gespeichert.';
} catch (_) { } catch (_) {
+6
View File
@@ -31,6 +31,7 @@ class BauteilController extends GetxController {
final minutenController = TextEditingController(text: '0'); final minutenController = TextEditingController(text: '0');
final gewichtController = TextEditingController(); final gewichtController = TextEditingController();
final abfallController = TextEditingController(text: '0'); final abfallController = TextEditingController(text: '0');
final gesamtpreisController = TextEditingController();
final _verbrauchControllers = <String, TextEditingController>{}; final _verbrauchControllers = <String, TextEditingController>{};
final filamente = <KaufFilamentModel>[].obs; final filamente = <KaufFilamentModel>[].obs;
final profile = <AnwendungModel>[].obs; final profile = <AnwendungModel>[].obs;
@@ -224,6 +225,7 @@ class BauteilController extends GetxController {
druckdauerMinuten: duration, druckdauerMinuten: duration,
gewichtGramm: InputValidators.number(gewichtController.text)!, gewichtGramm: InputValidators.number(gewichtController.text)!,
abfallGramm: InputValidators.number(abfallController.text)!, abfallGramm: InputValidators.number(abfallController.text)!,
gesamtpreis: InputValidators.number(gesamtpreisController.text),
fotoBase64: foto.value == null ? null : base64Encode(foto.value!), fotoBase64: foto.value == null ? null : base64Encode(foto.value!),
filamente: selectedFilamente.entries filamente: selectedFilamente.entries
.map( .map(
@@ -268,6 +270,8 @@ class BauteilController extends GetxController {
minutenController.text = '${entry.druckdauerMinuten % 60}'; minutenController.text = '${entry.druckdauerMinuten % 60}';
gewichtController.text = DisplayFormats.number(entry.gewichtGramm); gewichtController.text = DisplayFormats.number(entry.gewichtGramm);
abfallController.text = DisplayFormats.number(entry.abfallGramm); abfallController.text = DisplayFormats.number(entry.abfallGramm);
gesamtpreisController.text =
entry.gesamtpreis?.toString().replaceAll('.', ',') ?? '';
foto.value = entry.fotoBytes; foto.value = entry.fotoBytes;
for (final item in entry.filamente) { for (final item in entry.filamente) {
verbrauchController(item.filamentId).text = item.verbrauchGramm > 0 verbrauchController(item.filamentId).text = item.verbrauchGramm > 0
@@ -342,6 +346,7 @@ class BauteilController extends GetxController {
minutenController.text = '0'; minutenController.text = '0';
gewichtController.clear(); gewichtController.clear();
abfallController.text = '0'; abfallController.text = '0';
gesamtpreisController.clear();
selectedFilamente.clear(); selectedFilamente.clear();
for (final controller in _verbrauchControllers.values) { for (final controller in _verbrauchControllers.values) {
controller.clear(); controller.clear();
@@ -359,6 +364,7 @@ class BauteilController extends GetxController {
minutenController.dispose(); minutenController.dispose();
gewichtController.dispose(); gewichtController.dispose();
abfallController.dispose(); abfallController.dispose();
gesamtpreisController.dispose();
for (final controller in _verbrauchControllers.values) { for (final controller in _verbrauchControllers.values) {
controller.dispose(); controller.dispose();
} }
+12
View File
@@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
import '../models/kauf_filament_model.dart'; import '../models/kauf_filament_model.dart';
import '../models/bauteil_model.dart'; import '../models/bauteil_model.dart';
import '../models/anwendung_model.dart';
import '../helper/storage_keys.dart'; import '../helper/storage_keys.dart';
import '../helper/filament_bestand.dart'; import '../helper/filament_bestand.dart';
import '../helper/display_formats.dart'; import '../helper/display_formats.dart';
@@ -18,6 +19,16 @@ class HomeController extends GetxController {
final filamente = <KaufFilamentModel>[].obs; final filamente = <KaufFilamentModel>[].obs;
final verbrauch = <String, double>{}.obs; final verbrauch = <String, double>{}.obs;
final filamentIdsMitProfil = <String>{}.obs;
void loadProfile() {
filamentIdsMitProfil.assignAll(
storage
.getModelList(StorageKeys.anwendungen)
.map(AnwendungModel.fromJson)
.map((profile) => profile.filamentId),
);
}
void loadVerbrauch() { void loadVerbrauch() {
verbrauch.assignAll( verbrauch.assignAll(
@@ -133,6 +144,7 @@ class HomeController extends GetxController {
void loadFilamente() { void loadFilamente() {
loadVerbrauch(); loadVerbrauch();
loadProfile();
final data = storage.getModelList(storageKey); final data = storage.getModelList(storageKey);
filamente.assignAll(data.map(KaufFilamentModel.fromJson)); filamente.assignAll(data.map(KaufFilamentModel.fromJson));
+2
View File
@@ -1,6 +1,8 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DisplayFormats { class DisplayFormats {
static String currency(num value) =>
NumberFormat.currency(locale: 'de_AT', symbol: '€').format(value);
static String number(num value) => static String number(num value) =>
NumberFormat('0.##', 'de_AT').format(value); NumberFormat('0.##', 'de_AT').format(value);
static String date(DateTime value) => static String date(DateTime value) =>
+3
View File
@@ -9,6 +9,7 @@ import 'package:get_storage/get_storage.dart';
import 'services/get_storage_services.dart'; import 'services/get_storage_services.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'widgets/responsive_viewport.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -26,6 +27,8 @@ class MyApp extends StatelessWidget {
title: 'Flutter Filament Verwaltung', title: 'Flutter Filament Verwaltung',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light, theme: AppTheme.light,
builder: (context, child) =>
ResponsiveViewport(child: child ?? const SizedBox.shrink()),
initialBinding: SampleBindings(), initialBinding: SampleBindings(),
initialRoute: HomeView.routeName, initialRoute: HomeView.routeName,
getPages: SampleRouts.samplePages, getPages: SampleRouts.samplePages,
+1
View File
@@ -71,6 +71,7 @@ class BackupModel {
item.druckdauerMinuten <= 0 || item.druckdauerMinuten <= 0 ||
!_positive(item.gewichtGramm) || !_positive(item.gewichtGramm) ||
!_nonNegative(item.abfallGramm) || !_nonNegative(item.abfallGramm) ||
(item.gesamtpreis != null && !_nonNegative(item.gesamtpreis!)) ||
item.filamente.isEmpty || item.filamente.isEmpty ||
item.filamente.any( item.filamente.any(
(entry) => (entry) =>
+5
View File
@@ -34,6 +34,7 @@ class BauteilModel {
final int druckdauerMinuten; final int druckdauerMinuten;
final double gewichtGramm; final double gewichtGramm;
final double abfallGramm; final double abfallGramm;
final double? gesamtpreis;
final List<BauteilFilamentModel> filamente; final List<BauteilFilamentModel> filamente;
final String? fotoBase64; final String? fotoBase64;
@@ -46,6 +47,7 @@ class BauteilModel {
required this.gewichtGramm, required this.gewichtGramm,
required this.abfallGramm, required this.abfallGramm,
required this.filamente, required this.filamente,
this.gesamtpreis,
this.fotoBase64, this.fotoBase64,
}); });
@@ -71,6 +73,7 @@ class BauteilModel {
druckdauerMinuten: druckdauerMinuten, druckdauerMinuten: druckdauerMinuten,
gewichtGramm: gewichtGramm, gewichtGramm: gewichtGramm,
abfallGramm: abfallGramm, abfallGramm: abfallGramm,
gesamtpreis: gesamtpreis,
filamente: filamente, filamente: filamente,
fotoBase64: fotoBase64, fotoBase64: fotoBase64,
); );
@@ -83,6 +86,7 @@ class BauteilModel {
'druckdauerMinuten': druckdauerMinuten, 'druckdauerMinuten': druckdauerMinuten,
'gewichtGramm': gewichtGramm, 'gewichtGramm': gewichtGramm,
'abfallGramm': abfallGramm, 'abfallGramm': abfallGramm,
'gesamtpreis': gesamtpreis,
'filamente': filamente.map((item) => item.toJson()).toList(), 'filamente': filamente.map((item) => item.toJson()).toList(),
'fotoBase64': fotoBase64, 'fotoBase64': fotoBase64,
}; };
@@ -95,6 +99,7 @@ class BauteilModel {
druckdauerMinuten: json['druckdauerMinuten'] as int, druckdauerMinuten: json['druckdauerMinuten'] as int,
gewichtGramm: (json['gewichtGramm'] as num).toDouble(), gewichtGramm: (json['gewichtGramm'] as num).toDouble(),
abfallGramm: (json['abfallGramm'] as num).toDouble(), abfallGramm: (json['abfallGramm'] as num).toDouble(),
gesamtpreis: (json['gesamtpreis'] as num?)?.toDouble(),
fotoBase64: json['fotoBase64'] as String?, fotoBase64: json['fotoBase64'] as String?,
filamente: (json['filamente'] as List) filamente: (json['filamente'] as List)
.map( .map(
+17 -4
View File
@@ -6,6 +6,7 @@ import '../models/kauf_filament_model.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/filament_form.dart'; import '../widgets/filament_form.dart';
import '../widgets/filament_inventory.dart'; import '../widgets/filament_inventory.dart';
import '../widgets/app_version.dart';
import 'anwendung_view.dart'; import 'anwendung_view.dart';
import 'bauteil_view.dart'; import 'bauteil_view.dart';
import 'export_view.dart'; import 'export_view.dart';
@@ -27,25 +28,37 @@ class HomeView extends GetView<HomeController> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
title: const Row( title: LayoutBuilder(
builder: (context, constraints) => Row(
children: [ children: [
Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28), if (constraints.maxWidth >= 260) ...[
SizedBox(width: 12), const Icon(
Flexible( Icons.all_inbox_rounded,
color: AppTheme.teal,
size: 28,
),
const SizedBox(width: 12),
],
const Flexible(
child: Text( child: Text(
'Filamentverwaltung', 'Filamentverwaltung',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700), style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
), ),
), ),
], ],
), ),
), ),
),
drawer: Drawer( drawer: Drawer(
child: SafeArea( child: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
const ListTile(title: Text('Filamentverwaltung')), const ListTile(title: Text('Filamentverwaltung')),
const AppVersion(),
const Divider(),
ListTile( ListTile(
leading: const Icon(Icons.import_export), leading: const Icon(Icons.import_export),
title: const Text('Daten exportieren / importieren'), title: const Text('Daten exportieren / importieren'),
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AppVersion extends StatefulWidget {
const AppVersion({super.key});
@override
State<AppVersion> createState() => _AppVersionState();
}
class _AppVersionState extends State<AppVersion> {
late final Future<PackageInfo> _info = PackageInfo.fromPlatform();
@override
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
future: _info,
builder: (context, snapshot) {
final info = snapshot.data;
return ListTile(
leading: const Icon(Icons.info_outline),
title: Text(info == null ? 'Version' : 'Version ${info.version}'),
subtitle: Text(
info == null
? (snapshot.hasError ? 'Nicht verfügbar' : 'Wird geladen …')
: 'Build ${info.buildNumber}',
),
);
},
);
}
+4
View File
@@ -102,6 +102,10 @@ class BauteilCard extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
children: [ children: [
_detail(DisplayFormats.duration(bauteil.druckdauerMinuten)), _detail(DisplayFormats.duration(bauteil.druckdauerMinuten)),
if (bauteil.gesamtpreis != null)
_detail(
'Gesamtpreis ${DisplayFormats.currency(bauteil.gesamtpreis!)}',
),
_detail( _detail(
'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g', 'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g',
), ),
+7
View File
@@ -64,6 +64,13 @@ class BauteilForm extends StatelessWidget {
unit: 'g', unit: 'g',
validator: InputValidators.nonNegative, validator: InputValidators.nonNegative,
), ),
EntryNumberField(
controller: controller.gesamtpreisController,
label: 'Gesamtpreis (optional)',
unit: '€',
validator: (value) =>
InputValidators.nonNegative(value, optional: true),
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
+21 -14
View File
@@ -9,14 +9,10 @@ class BauteilPhotoPicker extends StatelessWidget {
final BauteilController controller; final BauteilController controller;
@override @override
Widget build(BuildContext context) => Obx( Widget build(BuildContext context) => LayoutBuilder(
() => Row( builder: (context, constraints) => Obx(() {
crossAxisAlignment: CrossAxisAlignment.start, final photo = BauteilPhoto(bytes: controller.foto.value);
children: [ final controls = Column(
BauteilPhoto(bytes: controller.foto.value),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
@@ -30,8 +26,7 @@ class BauteilPhotoPicker extends StatelessWidget {
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onPressed:
controller.isPickingPhoto.value || controller.isPickingPhoto.value || controller.isSaving.value
controller.isSaving.value
? null ? null
: controller.pickPhoto, : controller.pickPhoto,
icon: const Icon(Icons.add_photo_alternate_outlined), icon: const Icon(Icons.add_photo_alternate_outlined),
@@ -51,9 +46,21 @@ class BauteilPhotoPicker extends StatelessWidget {
], ],
), ),
], ],
), );
), if (constraints.maxWidth < 400) {
], return Column(
), crossAxisAlignment: CrossAxisAlignment.start,
children: [photo, const SizedBox(height: 12), controls],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
photo,
const SizedBox(width: 16),
Expanded(child: controls),
],
);
}),
); );
} }
+7
View File
@@ -12,12 +12,14 @@ class FilamentCard extends StatelessWidget {
required this.onTap, required this.onTap,
required this.onAction, required this.onAction,
this.verbrauchGramm = 0, this.verbrauchGramm = 0,
this.hatProfil = false,
}); });
final KaufFilamentModel filament; final KaufFilamentModel filament;
final VoidCallback onTap; final VoidCallback onTap;
final ValueChanged<String> onAction; final ValueChanged<String> onAction;
final double verbrauchGramm; final double verbrauchGramm;
final bool hatProfil;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -155,6 +157,11 @@ class FilamentCard extends StatelessWidget {
icon: Icons.layers_outlined, icon: Icons.layers_outlined,
), ),
_DetailPill(label: weight, icon: Icons.scale_outlined), _DetailPill(label: weight, icon: Icons.scale_outlined),
if (hatProfil)
const _DetailPill(
label: 'Profil erstellt',
icon: Icons.check_circle_outline,
),
], ],
), ),
const Padding( const Padding(
+4
View File
@@ -32,6 +32,7 @@ class FilamentInventory extends StatelessWidget {
sliver: Obx(() { sliver: Obx(() {
final filamente = controller.filamente.toList(); final filamente = controller.filamente.toList();
final verbrauch = Map<String, double>.from(controller.verbrauch); final verbrauch = Map<String, double>.from(controller.verbrauch);
final filamentIdsMitProfil = controller.filamentIdsMitProfil.toSet();
final total = filamente.fold<double>( final total = filamente.fold<double>(
0, 0,
(sum, item) => sum + item.preis, (sum, item) => sum + item.preis,
@@ -144,6 +145,9 @@ class FilamentInventory extends StatelessWidget {
key: ValueKey(filament.filamentId), key: ValueKey(filament.filamentId),
filament: filament, filament: filament,
verbrauchGramm: verbrauch[filament.filamentId] ?? 0, verbrauchGramm: verbrauch[filament.filamentId] ?? 0,
hatProfil: filamentIdsMitProfil.contains(
filament.filamentId,
),
onTap: () => onOpen(filament), onTap: () => onOpen(filament),
onAction: (action) => onAction(action, filament), onAction: (action) => onAction(action, filament),
); );
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// Keeps the navigator (including dialogs) usable in very narrow browser tabs.
/// The native Windows/Linux runners use the same minimum client width.
class ResponsiveViewport extends StatelessWidget {
const ResponsiveViewport({super.key, required this.child});
static const minimumWidth = 320.0;
final Widget child;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= minimumWidth) return child;
final media = MediaQuery.of(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
primary: false,
child: SizedBox(
width: minimumWidth,
height: constraints.maxHeight,
child: MediaQuery(
data: media.copyWith(size: Size(minimumWidth, media.size.height)),
child: child,
),
),
);
},
);
}
+2
View File
@@ -59,6 +59,8 @@ static void my_application_activate(GApplication* application) {
project, self->dart_entrypoint_arguments); project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project); FlView* view = fl_view_new(project);
// Match ResponsiveViewport.minimumWidth; GTK adds window decorations.
gtk_widget_set_size_request(GTK_WIDGET(view), 320, 480);
GdkRGBA background_color; GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000 // Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent. // for transparent.
@@ -7,8 +7,10 @@ import Foundation
import file_picker_darwin import file_picker_darwin
import file_selector_macos import file_selector_macos
import package_info_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
} }
+16
View File
@@ -493,6 +493,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev"
source: hosted
version: "10.2.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path: path:
dependency: transitive dependency: transitive
description: description:
+2 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.2+3
environment: environment:
sdk: ^3.13.0 sdk: ^3.13.0
@@ -41,6 +41,7 @@ dependencies:
uuid: ^4.6.0 uuid: ^4.6.0
file_selector: ^1.1.0 file_selector: ^1.1.0
file_picker: ^13.3.0 file_picker: ^13.3.0
package_info_plus: ^10.2.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+62
View File
@@ -0,0 +1,62 @@
param(
[ValidateSet('apk', 'appbundle', 'windows', 'web', 'ios', 'ipa', 'macos', 'linux')]
[string]$Target = 'apk',
[string]$FlutterCommand = 'flutter',
[string[]]$FlutterArguments = @()
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$pubspecPath = Join-Path $projectRoot 'pubspec.yaml'
$lockPath = Join-Path $projectRoot '.release-build.lock'
$buildLock = $null
$originalBytes = $null
$updated = $false
$succeeded = $false
try {
# Hold an exclusive lock; the OS releases it even if PowerShell is closed.
$buildLock = [System.IO.File]::Open(
$lockPath, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None
)
foreach ($argument in $FlutterArguments) {
if ($argument -match '^--(build-name|build-number|debug|profile|config-only)(=|$)') {
throw "Argument $argument ist im versionierten Release-Build nicht erlaubt."
}
}
$originalBytes = [System.IO.File]::ReadAllBytes($pubspecPath)
$source = [System.Text.Encoding]::UTF8.GetString($originalBytes)
$versionMatch = [regex]::Match($source, '(?m)^version:\s*(\d+)\.(\d+)\.(\d+)\+(\d+)[ \t]*\r?$')
if (-not $versionMatch.Success) {
throw 'In pubspec.yaml wird eine Version wie 1.0.0+1 erwartet.'
}
$patch = [long]::Parse($versionMatch.Groups[3].Value) + 1
$build = [long]::Parse($versionMatch.Groups[4].Value) + 1
$nextVersion = '{0}.{1}.{2}+{3}' -f $versionMatch.Groups[1].Value, $versionMatch.Groups[2].Value, $patch, $build
$replacement = "version: $nextVersion"
if ($versionMatch.Value.EndsWith("`r")) { $replacement += "`r" }
$nextSource = $source.Remove($versionMatch.Index, $versionMatch.Length).Insert($versionMatch.Index, $replacement)
[System.IO.File]::WriteAllText($pubspecPath, $nextSource, [System.Text.UTF8Encoding]::new($false))
$updated = $true
Push-Location $projectRoot
try {
Write-Host "Release $nextVersion ($Target) wird erstellt ..."
$global:LASTEXITCODE = 0
& $FlutterCommand build $Target --release @FlutterArguments
if ($LASTEXITCODE -ne 0) {
throw "Flutter-Build fehlgeschlagen (Exitcode $LASTEXITCODE)."
}
$succeeded = $true
Write-Host "Release $nextVersion erfolgreich. Die Version wurde in pubspec.yaml gespeichert."
} finally {
Pop-Location
}
} finally {
if ($updated -and -not $succeeded) {
[System.IO.File]::WriteAllBytes($pubspecPath, $originalBytes)
Write-Host 'Build nicht erfolgreich: Bisherige Version wiederhergestellt.'
}
if ($null -ne $buildLock) { $buildLock.Dispose() }
}
+60
View File
@@ -0,0 +1,60 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$testRoot = Join-Path $projectRoot ('build/release-script-test-' + [guid]::NewGuid().ToString('N'))
$testScripts = Join-Path $testRoot 'scripts'
New-Item -ItemType Directory -Path $testScripts -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'build_release.ps1') -Destination $testScripts
$pubspecPath = Join-Path $testRoot 'pubspec.yaml'
$original = "name: test_app`r`nversion: 1.2.9+41`r`n"
[System.IO.File]::WriteAllText($pubspecPath, $original)
$fakeFlutter = Join-Path $testRoot 'fake_flutter.ps1'
@'
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$BuildArguments)
if (($BuildArguments -join ' ') -notmatch '^build (apk|windows) --release') {
throw 'Unexpected Flutter arguments.'
}
Copy-Item -LiteralPath './pubspec.yaml' -Destination './built-version.yaml' -Force
if (Test-Path './fail-build') { $global:LASTEXITCODE = 1 } else { $global:LASTEXITCODE = 0 }
'@ | Set-Content -LiteralPath $fakeFlutter
$releaseScript = Join-Path $testScripts 'build_release.ps1'
function Assert-Version([string]$Expected) {
$pubspec = [System.IO.File]::ReadAllText($pubspecPath)
if ($pubspec -ne "name: test_app`r`nversion: $Expected`r`n") {
throw "Unexpected pubspec: $pubspec"
}
}
& $releaseScript -FlutterCommand $fakeFlutter
Assert-Version '1.2.10+42'
if ([System.IO.File]::ReadAllText((Join-Path $testRoot 'built-version.yaml')) -notmatch '1\.2\.10\+42') {
throw 'The build did not receive the new version.'
}
& $releaseScript -Target windows -FlutterCommand $fakeFlutter
Assert-Version '1.2.11+43'
New-Item -ItemType File -Path (Join-Path $testRoot 'fail-build') | Out-Null
$failureCaught = $false
try { & $releaseScript -FlutterCommand $fakeFlutter } catch { $failureCaught = $true }
if (-not $failureCaught) { throw 'Build failure was not reported.' }
Assert-Version '1.2.11+43'
$invalidCaught = $false
try {
& $releaseScript -FlutterCommand $fakeFlutter -FlutterArguments '--build-number=99'
} catch { $invalidCaught = $true }
if (-not $invalidCaught) { throw 'Manual version override was accepted.' }
Assert-Version '1.2.11+43'
$lock = [System.IO.File]::Open(
(Join-Path $testRoot '.release-build.lock'),
[System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
$lockCaught = $false
try {
try { & $releaseScript -FlutterCommand $fakeFlutter } catch { $lockCaught = $true }
} finally { $lock.Dispose() }
if (-not $lockCaught) { throw 'Concurrent release was not blocked.' }
Assert-Version '1.2.11+43'
Write-Host 'All release-script checks passed (success, repeat build, rollback, overrides, lock).'
+47
View File
@@ -236,6 +236,53 @@ void main() {
expect(find.textContaining('nicht gefunden'), findsOneWidget); expect(find.textContaining('nicht gefunden'), findsOneWidget);
}); });
testWidgets('Inventory marks saved profiles and updates after creating one', (
tester,
) async {
tester.view.physicalSize = const Size(1280, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
GetMaterialApp(
theme: AppTheme.light,
initialRoute: HomeView.routeName,
getPages: SampleRouts.samplePages,
),
);
await tester.pumpAndSettle();
final home = Get.find<HomeController>();
Finder badgeFor(String id) => find.descendant(
of: find.byKey(ValueKey(id)),
matching: find.text('Profil erstellt'),
);
expect(badgeFor('a'), findsNothing);
expect(badgeFor('b'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('a')));
await tester.pumpAndSettle();
final profileController = Get.find<AnwendungController>();
profileController.druckController.text = '220';
profileController.druckbettController.text = '60';
storage.failWrites = true;
await profileController.saveAnwendung();
expect(home.filamentIdsMitProfil, isNot(contains('a')));
storage.failWrites = false;
await profileController.saveAnwendung();
Get.back<void>();
await tester.pumpAndSettle();
expect(badgeFor('a'), findsOneWidget);
expect(badgeFor('b'), findsOneWidget);
// Reloads after imports must also clear badges for removed profiles.
await storage.saveModelList(AnwendungController.storageKey, []);
home.loadFilamente();
await tester.pumpAndSettle();
expect(find.text('Profil erstellt'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets( testWidgets(
'Multicolor part persists both filaments and matching processing profile', 'Multicolor part persists both filaments and matching processing profile',
(tester) async { (tester) async {
+49
View File
@@ -7,6 +7,7 @@ import 'package:get/get.dart';
import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart';
import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
import 'package:ertl_filament_verwaltung/helper/display_formats.dart';
import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; import 'package:ertl_filament_verwaltung/models/bauteil_model.dart';
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart';
@@ -143,6 +144,7 @@ void main() {
}); });
expect(legacy.filamente.single.verbrauchGramm, 0); expect(legacy.filamente.single.verbrauchGramm, 0);
expect(legacy.fotoBytes, isNull); expect(legacy.fotoBytes, isNull);
expect(legacy.gesamtpreis, isNull);
}, },
); );
@@ -151,6 +153,7 @@ void main() {
(tester) async { (tester) async {
await show(tester); await show(tester);
fill(); fill();
controller.gesamtpreisController.text = '12,50';
photos.result = png; photos.result = png;
await controller.pickPhoto(); await controller.pickPhoto();
await controller.saveBauteil(); await controller.saveBauteil();
@@ -158,9 +161,19 @@ void main() {
expect(remaining('b'), 960); expect(remaining('b'), 960);
final originalId = controller.bauteile.single.bauteilId; final originalId = controller.bauteile.single.bauteilId;
final originalDate = controller.bauteile.single.erstelltAm; final originalDate = controller.bauteile.single.erstelltAm;
expect(controller.bauteile.single.gesamtpreis, 12.5);
expect(
storage
.getModelList(BauteilController.storageKey)
.single['gesamtpreis'],
12.5,
);
expect(controller.gesamtpreisController.text, isEmpty);
await edit(tester, controller.bauteile.single); await edit(tester, controller.bauteile.single);
expect(controller.foto.value, png); expect(controller.foto.value, png);
expect(controller.gesamtpreisController.text, '12,5');
controller.gesamtpreisController.text = '18,90';
expect(controller.verfuegbarGramm('a'), 1000); expect(controller.verfuegbarGramm('a'), 1000);
controller.verbrauchController('a').text = '20'; controller.verbrauchController('a').text = '20';
controller.verbrauchController('b').text = '80'; controller.verbrauchController('b').text = '80';
@@ -180,6 +193,10 @@ void main() {
controller.bauteile.every((item) => item.fotoBase64 != null), controller.bauteile.every((item) => item.fotoBase64 != null),
isTrue, isTrue,
); );
expect(
controller.bauteile.every((item) => item.gesamtpreis == 18.9),
isTrue,
);
expect(remaining('a'), 960); expect(remaining('a'), 960);
expect(remaining('b'), 840); expect(remaining('b'), 840);
await controller.deleteBauteil(originalId); await controller.deleteBauteil(originalId);
@@ -190,6 +207,7 @@ void main() {
expect(remaining('a'), 980); expect(remaining('a'), 980);
final reloaded = Get.put(BauteilController(), tag: 'reload'); final reloaded = Get.put(BauteilController(), tag: 'reload');
expect(reloaded.bauteile.single.fotoBytes, png); expect(reloaded.bauteile.single.fotoBytes, png);
expect(reloaded.bauteile.single.gesamtpreis, 18.9);
expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80); expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80);
// Purchases retain the original weight; the persisted ledger is deducted. // Purchases retain the original weight; the persisted ledger is deducted.
expect( expect(
@@ -218,6 +236,32 @@ void main() {
}, },
); );
testWidgets(
'Total price rejects invalid amounts, accepts zero and can be cleared',
(tester) async {
await show(tester);
fill();
for (final value in ['-1', 'NaN', 'Infinity', 'abc']) {
controller.gesamtpreisController.text = value;
await controller.saveBauteil();
expect(storage.getModelList(BauteilController.storageKey), isEmpty);
}
controller.gesamtpreisController.text = '0';
await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, 0);
await edit(tester, controller.bauteile.single);
controller.gesamtpreisController.clear();
await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, isNull);
expect(
storage
.getModelList(BauteilController.storageKey)
.single['gesamtpreis'],
isNull,
);
},
);
testWidgets( testWidgets(
'Editing credits only the edited booking; duplicates need new stock', 'Editing credits only the edited booking; duplicates need new stock',
(tester) async { (tester) async {
@@ -310,6 +354,7 @@ void main() {
(tester) async { (tester) async {
await show(tester, narrow: true); await show(tester, narrow: true);
fill(); fill();
controller.gesamtpreisController.text = '12,50';
photos.result = png; photos.result = png;
await controller.pickPhoto(); await controller.pickPhoto();
await controller.saveBauteil(); await controller.saveBauteil();
@@ -320,6 +365,10 @@ void main() {
scrollable: find.byType(Scrollable).first, scrollable: find.byType(Scrollable).first,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(
find.text('Gesamtpreis ${DisplayFormats.currency(12.5)}'),
findsOneWidget,
);
expect( expect(
find.descendant( find.descendant(
of: find.byType(BauteilCard), of: find.byType(BauteilCard),
+19
View File
@@ -88,6 +88,7 @@ void main() {
druckdauerMinuten: 60, druckdauerMinuten: 60,
gewichtGramm: 40, gewichtGramm: 40,
abfallGramm: 10, abfallGramm: 10,
gesamtpreis: 12.5,
filamente: [ filamente: [
const BauteilFilamentModel( const BauteilFilamentModel(
filamentId: 'f1', filamentId: 'f1',
@@ -129,6 +130,7 @@ void main() {
await controller.importData(); await controller.importData();
expect(storage.tables, fixture()); expect(storage.tables, fixture());
expect(home.materialien, ['PA12', 'PLA']); expect(home.materialien, ['PA12', 'PLA']);
expect(home.filamentIdsMitProfil, contains('f1'));
expect(home.restgewicht(home.filamente.single), 950); expect(home.restgewicht(home.filamente.single), 950);
expect(controller.hasError.value, isFalse); expect(controller.hasError.value, isFalse);
}, },
@@ -174,6 +176,23 @@ void main() {
expect(storage.tables, fixture()); expect(storage.tables, fixture());
}); });
test('Backup imports accept legacy parts without price and reject negative prices', () {
final legacy = fixture();
legacy[StorageKeys.bauteile]!.single.remove('gesamtpreis');
final decoded = BackupModel.decode(BackupModel(legacy).encode());
expect(
BauteilModel.fromJson(decoded.data[StorageKeys.bauteile]!.single)
.gesamtpreis,
isNull,
);
final invalid = fixture();
invalid[StorageKeys.bauteile]!.single['gesamtpreis'] = -1;
expect(
() => BackupModel.decode(BackupModel(invalid).encode()),
throwsFormatException,
);
});
test('A failed import restores previously written tables', () async { test('A failed import restores previously written tables', () async {
storage.tables.addAll(fixture()); storage.tables.addAll(fixture());
files.source = BackupModel({for (final key in BackupModel.keys) key: []}) files.source = BackupModel({for (final key in BackupModel.keys) key: []})
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:ertl_filament_verwaltung/controller/anwendung_controller.dart';
import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart';
import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
import 'package:ertl_filament_verwaltung/helper/storage_keys.dart';
import 'package:ertl_filament_verwaltung/main.dart';
import 'package:ertl_filament_verwaltung/models/bauteil_model.dart';
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
import 'package:ertl_filament_verwaltung/pages/anwendung_view.dart';
import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart';
import 'package:ertl_filament_verwaltung/pages/export_view.dart';
import 'package:ertl_filament_verwaltung/pages/home_view.dart';
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart';
import 'package:ertl_filament_verwaltung/widgets/filament_card.dart';
import 'helpers/memory_storage_service.dart';
void main() {
setUp(() {
Get.testMode = true;
final storage = MemoryStorageService();
storage.tables[StorageKeys.filamente] = [
KaufFilamentModel(
filamentId: 'filament',
kaufdatum: DateTime(2026),
haendler: 'Ein Händler mit einem besonders langen Namen',
marke: 'Eine besonders lange Filamentmarke',
material: 'Benutzerdefiniertes Material mit langem Namen',
preis: 12345.67,
farbe: 'Ein lang beschriebener Farbton',
gewicht: '1000',
).toJson(),
];
storage.tables[StorageKeys.bauteile] = [
BauteilModel(
bauteilId: 'part',
erstelltAm: DateTime(2026),
name: 'Ein besonders lang benanntes Bauteil',
verwendung: 'Eine ausführliche Beschreibung der Verwendung',
druckdauerMinuten: 120,
gewichtGramm: 40,
abfallGramm: 10,
filamente: [
const BauteilFilamentModel(
filamentId: 'filament',
verbrauchGramm: 50,
),
],
).toJson(),
];
Get.put<GetStorageService>(storage);
});
tearDown(() => Get.reset());
Future<void> resize(WidgetTester tester, double width) async {
tester.view.physicalSize = Size(width, 640);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: 'Width $width');
}
for (final route in [
HomeView.routeName,
BauteilView.routeName,
'${AnwendungView.routeName}/filament',
ExportView.routeName,
]) {
testWidgets(
'$route survives resizing across desktop and narrow browser widths',
(tester) async {
tester.view.physicalSize = const Size(1280, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
if (route != HomeView.routeName) {
Get.toNamed(route);
await tester.pumpAndSettle();
}
if (route == BauteilView.routeName) {
Get.find<BauteilController>().toggleFilament('filament', true);
}
if (route.startsWith(AnwendungView.routeName)) {
Get.find<AnwendungController>().sonstigesController.text = 'Entwurf';
}
for (final width in [
900.0,
899.0,
600.0,
360.0,
320.0,
240.0,
320.0,
1280.0,
]) {
await resize(tester, width);
expect(Get.currentRoute, route);
if (route == HomeView.routeName || route == BauteilView.routeName) {
final card = route == HomeView.routeName
? find.byType(FilamentCard)
: find.byType(BauteilCard);
final scroll = route == HomeView.routeName
? Get.find<HomeController>().pageScrollController
: Get.find<BauteilController>().scrollController;
scroll.jumpTo(scroll.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(card, findsOneWidget);
expect(
tester.takeException(),
isNull,
reason: 'Card at width $width',
);
scroll.jumpTo(0);
await tester.pumpAndSettle();
}
}
if (route.startsWith(AnwendungView.routeName)) {
expect(
Get.find<AnwendungController>().sonstigesController.text,
'Entwurf',
);
}
if (route == BauteilView.routeName) {
expect(
Get.find<BauteilController>().selectedFilamente,
contains('filament'),
);
}
},
);
}
}
+19
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; import 'package:ertl_filament_verwaltung/controller/home_controller.dart';
import 'package:ertl_filament_verwaltung/helper/storage_keys.dart'; import 'package:ertl_filament_verwaltung/helper/storage_keys.dart';
@@ -17,6 +18,13 @@ void main() {
late HomeController controller; late HomeController controller;
setUp(() { setUp(() {
PackageInfo.setMockInitialValues(
appName: 'Filamentverwaltung',
packageName: 'com.example.ertl_filament_verwaltung',
version: '1.2.3',
buildNumber: '42',
buildSignature: '',
);
Get.testMode = true; Get.testMode = true;
Get.put<GetStorageService>(MemoryStorageService()); Get.put<GetStorageService>(MemoryStorageService());
controller = Get.put(HomeController()); controller = Get.put(HomeController());
@@ -46,6 +54,17 @@ void main() {
gewicht: '1000', gewicht: '1000',
); );
testWidgets('Hamburger menu displays the installed version and build', (
tester,
) async {
await showHome(tester, const Size(320, 640));
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
expect(find.text('Version 1.2.3'), findsOneWidget);
expect(find.text('Build 42'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Desktop shows both panes and edits PLA+', (tester) async { testWidgets('Desktop shows both panes and edits PLA+', (tester) async {
controller.filamente.add(sample()); controller.filamente.add(sample());
await showHome(tester, const Size(1280, 900)); await showHome(tester, const Size(1280, 900));
+13
View File
@@ -51,6 +51,19 @@ LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message, FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam, WPARAM const wparam,
LPARAM const lparam) noexcept { LPARAM const lparam) noexcept {
if (message == WM_GETMINMAXINFO) {
// Logical client size; include the DPI-scaled title bar and window borders.
const UINT dpi = GetDpiForWindow(hwnd);
RECT minimum = {0, 0, MulDiv(320, dpi, 96), MulDiv(480, dpi, 96)};
AdjustWindowRectExForDpi(
&minimum, static_cast<DWORD>(GetWindowLongPtr(hwnd, GWL_STYLE)), FALSE,
static_cast<DWORD>(GetWindowLongPtr(hwnd, GWL_EXSTYLE)), dpi);
auto* sizes = reinterpret_cast<MINMAXINFO*>(lparam);
sizes->ptMinTrackSize.x = minimum.right - minimum.left;
sizes->ptMinTrackSize.y = minimum.bottom - minimum.top;
return 0;
}
// Give Flutter, including plugins, an opportunity to handle window messages. // Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) { if (flutter_controller_) {
std::optional<LRESULT> result = std::optional<LRESULT> result =
+2 -2
View File
@@ -26,8 +26,8 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1400, 768);
if (!window.Create(L"ertl_filament_verwaltung", origin, size)) { if (!window.Create(L"Filament Verwaltung", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);