From 77fbe16867a7a021eab0164257713737889f336d Mon Sep 17 00:00:00 2001 From: atseirjo Date: Wed, 28 Oct 2026 12:32:49 +0100 Subject: [PATCH] price per item is printed --- .gitignore | 2 + README.md | 36 +++++ build_release.cmd | 3 + lib/controller/anwendung_controller.dart | 3 + lib/controller/bauteil_controller.dart | 6 + lib/controller/home_controller.dart | 12 ++ lib/helper/display_formats.dart | 2 + lib/main.dart | 3 + lib/models/backup_model.dart | 1 + lib/models/bauteil_model.dart | 5 + lib/pages/home_view.dart | 33 +++-- lib/widgets/app_version.dart | 30 ++++ lib/widgets/bauteil_card.dart | 4 + lib/widgets/bauteil_form.dart | 7 + lib/widgets/bauteil_photo_picker.dart | 91 ++++++------ lib/widgets/filament_card.dart | 7 + lib/widgets/filament_inventory.dart | 4 + lib/widgets/responsive_viewport.dart | 30 ++++ linux/runner/my_application.cc | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 16 +++ pubspec.yaml | 3 +- scripts/build_release.ps1 | 62 ++++++++ scripts/test_build_release.ps1 | 60 ++++++++ test/anwendung_bauteil_test.dart | 47 ++++++ test/bauteil_bestand_test.dart | 49 +++++++ test/export_test.dart | 19 +++ test/responsive_layout_test.dart | 136 ++++++++++++++++++ test/widget_test.dart | 19 +++ windows/runner/flutter_window.cpp | 13 ++ windows/runner/main.cpp | 4 +- 31 files changed, 656 insertions(+), 55 deletions(-) create mode 100644 build_release.cmd create mode 100644 lib/widgets/app_version.dart create mode 100644 lib/widgets/responsive_viewport.dart create mode 100644 scripts/build_release.ps1 create mode 100644 scripts/test_build_release.ps1 create mode 100644 test/responsive_layout_test.dart diff --git a/.gitignore b/.gitignore index 79f7eca..2a07062 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ app.*.map.json # Widget Preview related .widget_preview/ +# Exclusive lock used by scripts/build_release.ps1. +.release-build.lock diff --git a/README.md b/README.md index 208ecec..e0be40f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,42 @@ 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 This project is a starting point for a Flutter application. diff --git a/build_release.cmd b/build_release.cmd new file mode 100644 index 0000000..3997cf8 --- /dev/null +++ b/build_release.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_release.ps1" %* +exit /b %errorlevel% diff --git a/lib/controller/anwendung_controller.dart b/lib/controller/anwendung_controller.dart index d21a5fc..8061871 100644 --- a/lib/controller/anwendung_controller.dart +++ b/lib/controller/anwendung_controller.dart @@ -77,6 +77,9 @@ class AnwendungController extends GetxController { entry.toJson(), ]); anwendungen.assignAll([entry]); + if (Get.isRegistered()) { + Get.find().loadProfile(); + } hasError.value = false; message.value = 'Verarbeitungsprofil gespeichert.'; } catch (_) { diff --git a/lib/controller/bauteil_controller.dart b/lib/controller/bauteil_controller.dart index d937ac7..335fcc2 100644 --- a/lib/controller/bauteil_controller.dart +++ b/lib/controller/bauteil_controller.dart @@ -31,6 +31,7 @@ class BauteilController extends GetxController { final minutenController = TextEditingController(text: '0'); final gewichtController = TextEditingController(); final abfallController = TextEditingController(text: '0'); + final gesamtpreisController = TextEditingController(); final _verbrauchControllers = {}; final filamente = [].obs; final profile = [].obs; @@ -224,6 +225,7 @@ class BauteilController extends GetxController { druckdauerMinuten: duration, gewichtGramm: InputValidators.number(gewichtController.text)!, abfallGramm: InputValidators.number(abfallController.text)!, + gesamtpreis: InputValidators.number(gesamtpreisController.text), fotoBase64: foto.value == null ? null : base64Encode(foto.value!), filamente: selectedFilamente.entries .map( @@ -268,6 +270,8 @@ class BauteilController extends GetxController { minutenController.text = '${entry.druckdauerMinuten % 60}'; gewichtController.text = DisplayFormats.number(entry.gewichtGramm); abfallController.text = DisplayFormats.number(entry.abfallGramm); + gesamtpreisController.text = + entry.gesamtpreis?.toString().replaceAll('.', ',') ?? ''; foto.value = entry.fotoBytes; for (final item in entry.filamente) { verbrauchController(item.filamentId).text = item.verbrauchGramm > 0 @@ -342,6 +346,7 @@ class BauteilController extends GetxController { minutenController.text = '0'; gewichtController.clear(); abfallController.text = '0'; + gesamtpreisController.clear(); selectedFilamente.clear(); for (final controller in _verbrauchControllers.values) { controller.clear(); @@ -359,6 +364,7 @@ class BauteilController extends GetxController { minutenController.dispose(); gewichtController.dispose(); abfallController.dispose(); + gesamtpreisController.dispose(); for (final controller in _verbrauchControllers.values) { controller.dispose(); } diff --git a/lib/controller/home_controller.dart b/lib/controller/home_controller.dart index 46ff34d..c27c1eb 100644 --- a/lib/controller/home_controller.dart +++ b/lib/controller/home_controller.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import '../models/kauf_filament_model.dart'; import '../models/bauteil_model.dart'; +import '../models/anwendung_model.dart'; import '../helper/storage_keys.dart'; import '../helper/filament_bestand.dart'; import '../helper/display_formats.dart'; @@ -18,6 +19,16 @@ class HomeController extends GetxController { final filamente = [].obs; final verbrauch = {}.obs; + final filamentIdsMitProfil = {}.obs; + + void loadProfile() { + filamentIdsMitProfil.assignAll( + storage + .getModelList(StorageKeys.anwendungen) + .map(AnwendungModel.fromJson) + .map((profile) => profile.filamentId), + ); + } void loadVerbrauch() { verbrauch.assignAll( @@ -133,6 +144,7 @@ class HomeController extends GetxController { void loadFilamente() { loadVerbrauch(); + loadProfile(); final data = storage.getModelList(storageKey); filamente.assignAll(data.map(KaufFilamentModel.fromJson)); diff --git a/lib/helper/display_formats.dart b/lib/helper/display_formats.dart index 3e81334..e207d04 100644 --- a/lib/helper/display_formats.dart +++ b/lib/helper/display_formats.dart @@ -1,6 +1,8 @@ import 'package:intl/intl.dart'; class DisplayFormats { + static String currency(num value) => + NumberFormat.currency(locale: 'de_AT', symbol: '€').format(value); static String number(num value) => NumberFormat('0.##', 'de_AT').format(value); static String date(DateTime value) => diff --git a/lib/main.dart b/lib/main.dart index 2ecf331..9eec5bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:get_storage/get_storage.dart'; import 'services/get_storage_services.dart'; import 'theme/app_theme.dart'; +import 'widgets/responsive_viewport.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -26,6 +27,8 @@ class MyApp extends StatelessWidget { title: 'Flutter Filament Verwaltung', debugShowCheckedModeBanner: false, theme: AppTheme.light, + builder: (context, child) => + ResponsiveViewport(child: child ?? const SizedBox.shrink()), initialBinding: SampleBindings(), initialRoute: HomeView.routeName, getPages: SampleRouts.samplePages, diff --git a/lib/models/backup_model.dart b/lib/models/backup_model.dart index 985f039..f1365d5 100644 --- a/lib/models/backup_model.dart +++ b/lib/models/backup_model.dart @@ -71,6 +71,7 @@ class BackupModel { item.druckdauerMinuten <= 0 || !_positive(item.gewichtGramm) || !_nonNegative(item.abfallGramm) || + (item.gesamtpreis != null && !_nonNegative(item.gesamtpreis!)) || item.filamente.isEmpty || item.filamente.any( (entry) => diff --git a/lib/models/bauteil_model.dart b/lib/models/bauteil_model.dart index 5a4e048..10c741a 100644 --- a/lib/models/bauteil_model.dart +++ b/lib/models/bauteil_model.dart @@ -34,6 +34,7 @@ class BauteilModel { final int druckdauerMinuten; final double gewichtGramm; final double abfallGramm; + final double? gesamtpreis; final List filamente; final String? fotoBase64; @@ -46,6 +47,7 @@ class BauteilModel { required this.gewichtGramm, required this.abfallGramm, required this.filamente, + this.gesamtpreis, this.fotoBase64, }); @@ -71,6 +73,7 @@ class BauteilModel { druckdauerMinuten: druckdauerMinuten, gewichtGramm: gewichtGramm, abfallGramm: abfallGramm, + gesamtpreis: gesamtpreis, filamente: filamente, fotoBase64: fotoBase64, ); @@ -83,6 +86,7 @@ class BauteilModel { 'druckdauerMinuten': druckdauerMinuten, 'gewichtGramm': gewichtGramm, 'abfallGramm': abfallGramm, + 'gesamtpreis': gesamtpreis, 'filamente': filamente.map((item) => item.toJson()).toList(), 'fotoBase64': fotoBase64, }; @@ -95,6 +99,7 @@ class BauteilModel { druckdauerMinuten: json['druckdauerMinuten'] as int, gewichtGramm: (json['gewichtGramm'] as num).toDouble(), abfallGramm: (json['abfallGramm'] as num).toDouble(), + gesamtpreis: (json['gesamtpreis'] as num?)?.toDouble(), fotoBase64: json['fotoBase64'] as String?, filamente: (json['filamente'] as List) .map( diff --git a/lib/pages/home_view.dart b/lib/pages/home_view.dart index 903fd3b..fb60941 100644 --- a/lib/pages/home_view.dart +++ b/lib/pages/home_view.dart @@ -6,6 +6,7 @@ import '../models/kauf_filament_model.dart'; import '../theme/app_theme.dart'; import '../widgets/filament_form.dart'; import '../widgets/filament_inventory.dart'; +import '../widgets/app_version.dart'; import 'anwendung_view.dart'; import 'bauteil_view.dart'; import 'export_view.dart'; @@ -27,17 +28,27 @@ class HomeView extends GetView { ), const SizedBox(width: 8), ], - title: const Row( - children: [ - Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28), - SizedBox(width: 12), - Flexible( - child: Text( - 'Filamentverwaltung', - style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700), + title: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + if (constraints.maxWidth >= 260) ...[ + const Icon( + Icons.all_inbox_rounded, + color: AppTheme.teal, + size: 28, + ), + const SizedBox(width: 12), + ], + const Flexible( + child: Text( + 'Filamentverwaltung', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700), + ), ), - ), - ], + ], + ), ), ), drawer: Drawer( @@ -46,6 +57,8 @@ class HomeView extends GetView { padding: const EdgeInsets.all(16), children: [ const ListTile(title: Text('Filamentverwaltung')), + const AppVersion(), + const Divider(), ListTile( leading: const Icon(Icons.import_export), title: const Text('Daten exportieren / importieren'), diff --git a/lib/widgets/app_version.dart b/lib/widgets/app_version.dart new file mode 100644 index 0000000..ed9b572 --- /dev/null +++ b/lib/widgets/app_version.dart @@ -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 createState() => _AppVersionState(); +} + +class _AppVersionState extends State { + late final Future _info = PackageInfo.fromPlatform(); + + @override + Widget build(BuildContext context) => FutureBuilder( + 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}', + ), + ); + }, + ); +} diff --git a/lib/widgets/bauteil_card.dart b/lib/widgets/bauteil_card.dart index 0f02777..e1ba145 100644 --- a/lib/widgets/bauteil_card.dart +++ b/lib/widgets/bauteil_card.dart @@ -102,6 +102,10 @@ class BauteilCard extends StatelessWidget { runSpacing: 8, children: [ _detail(DisplayFormats.duration(bauteil.druckdauerMinuten)), + if (bauteil.gesamtpreis != null) + _detail( + 'Gesamtpreis ${DisplayFormats.currency(bauteil.gesamtpreis!)}', + ), _detail( 'Bauteil ${DisplayFormats.number(bauteil.gewichtGramm)} g', ), diff --git a/lib/widgets/bauteil_form.dart b/lib/widgets/bauteil_form.dart index 0aa9244..d7711f1 100644 --- a/lib/widgets/bauteil_form.dart +++ b/lib/widgets/bauteil_form.dart @@ -64,6 +64,13 @@ class BauteilForm extends StatelessWidget { unit: 'g', validator: InputValidators.nonNegative, ), + EntryNumberField( + controller: controller.gesamtpreisController, + label: 'Gesamtpreis (optional)', + unit: '€', + validator: (value) => + InputValidators.nonNegative(value, optional: true), + ), ], ), const SizedBox(height: 24), diff --git a/lib/widgets/bauteil_photo_picker.dart b/lib/widgets/bauteil_photo_picker.dart index 1085142..484fd3a 100644 --- a/lib/widgets/bauteil_photo_picker.dart +++ b/lib/widgets/bauteil_photo_picker.dart @@ -9,51 +9,58 @@ class BauteilPhotoPicker extends StatelessWidget { final BauteilController controller; @override - Widget build(BuildContext context) => Obx( - () => Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BauteilPhoto(bytes: controller.foto.value), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => Obx(() { + final photo = BauteilPhoto(bytes: controller.foto.value); + final controls = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Bauteilfoto (optional)', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - const Text( - 'Bauteilfoto (optional)', - style: TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: - controller.isPickingPhoto.value || - controller.isSaving.value - ? null - : controller.pickPhoto, - icon: const Icon(Icons.add_photo_alternate_outlined), - label: Text( - controller.isPickingPhoto.value - ? 'Wird geladen …' - : 'Foto auswählen', - ), - ), - if (controller.foto.value != null) - TextButton( - onPressed: controller.isSaving.value - ? null - : controller.removePhoto, - child: const Text('Foto entfernen'), - ), - ], + OutlinedButton.icon( + onPressed: + controller.isPickingPhoto.value || controller.isSaving.value + ? null + : controller.pickPhoto, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: Text( + controller.isPickingPhoto.value + ? 'Wird geladen …' + : 'Foto auswählen', + ), ), + if (controller.foto.value != null) + TextButton( + onPressed: controller.isSaving.value + ? null + : controller.removePhoto, + child: const Text('Foto entfernen'), + ), ], ), - ), - ], - ), + ], + ); + 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), + ], + ); + }), ); } diff --git a/lib/widgets/filament_card.dart b/lib/widgets/filament_card.dart index 2f6aaa7..068d93a 100644 --- a/lib/widgets/filament_card.dart +++ b/lib/widgets/filament_card.dart @@ -12,12 +12,14 @@ class FilamentCard extends StatelessWidget { required this.onTap, required this.onAction, this.verbrauchGramm = 0, + this.hatProfil = false, }); final KaufFilamentModel filament; final VoidCallback onTap; final ValueChanged onAction; final double verbrauchGramm; + final bool hatProfil; @override Widget build(BuildContext context) { @@ -155,6 +157,11 @@ class FilamentCard extends StatelessWidget { icon: Icons.layers_outlined, ), _DetailPill(label: weight, icon: Icons.scale_outlined), + if (hatProfil) + const _DetailPill( + label: 'Profil erstellt', + icon: Icons.check_circle_outline, + ), ], ), const Padding( diff --git a/lib/widgets/filament_inventory.dart b/lib/widgets/filament_inventory.dart index 17ec659..908ed29 100644 --- a/lib/widgets/filament_inventory.dart +++ b/lib/widgets/filament_inventory.dart @@ -32,6 +32,7 @@ class FilamentInventory extends StatelessWidget { sliver: Obx(() { final filamente = controller.filamente.toList(); final verbrauch = Map.from(controller.verbrauch); + final filamentIdsMitProfil = controller.filamentIdsMitProfil.toSet(); final total = filamente.fold( 0, (sum, item) => sum + item.preis, @@ -144,6 +145,9 @@ class FilamentInventory extends StatelessWidget { key: ValueKey(filament.filamentId), filament: filament, verbrauchGramm: verbrauch[filament.filamentId] ?? 0, + hatProfil: filamentIdsMitProfil.contains( + filament.filamentId, + ), onTap: () => onOpen(filament), onAction: (action) => onAction(action, filament), ); diff --git a/lib/widgets/responsive_viewport.dart b/lib/widgets/responsive_viewport.dart new file mode 100644 index 0000000..b7ac886 --- /dev/null +++ b/lib/widgets/responsive_viewport.dart @@ -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, + ), + ), + ); + }, + ); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 1a93c0d..b052a7b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -59,6 +59,8 @@ static void my_application_activate(GApplication* application) { project, self->dart_entrypoint_arguments); 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; // Background defaults to black, override it here if necessary, e.g. #00000000 // for transparent. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ac14e24..275fea1 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import file_picker_darwin import file_selector_macos +import package_info_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 1573fa7..47699e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -493,6 +493,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e94edbc..16978cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.0.0+1 +version: 1.0.2+3 environment: sdk: ^3.13.0 @@ -41,6 +41,7 @@ dependencies: uuid: ^4.6.0 file_selector: ^1.1.0 file_picker: ^13.3.0 + package_info_plus: ^10.2.2 dev_dependencies: flutter_test: diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 new file mode 100644 index 0000000..7bdd3d3 --- /dev/null +++ b/scripts/build_release.ps1 @@ -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() } +} diff --git a/scripts/test_build_release.ps1 b/scripts/test_build_release.ps1 new file mode 100644 index 0000000..37a9c94 --- /dev/null +++ b/scripts/test_build_release.ps1 @@ -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).' diff --git a/test/anwendung_bauteil_test.dart b/test/anwendung_bauteil_test.dart index 08ee75c..30d9033 100644 --- a/test/anwendung_bauteil_test.dart +++ b/test/anwendung_bauteil_test.dart @@ -236,6 +236,53 @@ void main() { 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(); + 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(); + 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(); + 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( 'Multicolor part persists both filaments and matching processing profile', (tester) async { diff --git a/test/bauteil_bestand_test.dart b/test/bauteil_bestand_test.dart index a39d2ca..7c5e777 100644 --- a/test/bauteil_bestand_test.dart +++ b/test/bauteil_bestand_test.dart @@ -7,6 +7,7 @@ import 'package:get/get.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/display_formats.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/bauteil_view.dart'; @@ -143,6 +144,7 @@ void main() { }); expect(legacy.filamente.single.verbrauchGramm, 0); expect(legacy.fotoBytes, isNull); + expect(legacy.gesamtpreis, isNull); }, ); @@ -151,6 +153,7 @@ void main() { (tester) async { await show(tester); fill(); + controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); @@ -158,9 +161,19 @@ void main() { expect(remaining('b'), 960); final originalId = controller.bauteile.single.bauteilId; 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); expect(controller.foto.value, png); + expect(controller.gesamtpreisController.text, '12,5'); + controller.gesamtpreisController.text = '18,90'; expect(controller.verfuegbarGramm('a'), 1000); controller.verbrauchController('a').text = '20'; controller.verbrauchController('b').text = '80'; @@ -180,6 +193,10 @@ void main() { controller.bauteile.every((item) => item.fotoBase64 != null), isTrue, ); + expect( + controller.bauteile.every((item) => item.gesamtpreis == 18.9), + isTrue, + ); expect(remaining('a'), 960); expect(remaining('b'), 840); await controller.deleteBauteil(originalId); @@ -190,6 +207,7 @@ void main() { expect(remaining('a'), 980); final reloaded = Get.put(BauteilController(), tag: 'reload'); expect(reloaded.bauteile.single.fotoBytes, png); + expect(reloaded.bauteile.single.gesamtpreis, 18.9); expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80); // Purchases retain the original weight; the persisted ledger is deducted. 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( 'Editing credits only the edited booking; duplicates need new stock', (tester) async { @@ -310,6 +354,7 @@ void main() { (tester) async { await show(tester, narrow: true); fill(); + controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); @@ -320,6 +365,10 @@ void main() { scrollable: find.byType(Scrollable).first, ); await tester.pumpAndSettle(); + expect( + find.text('Gesamtpreis ${DisplayFormats.currency(12.5)}'), + findsOneWidget, + ); expect( find.descendant( of: find.byType(BauteilCard), diff --git a/test/export_test.dart b/test/export_test.dart index 26cd404..a55a4fe 100644 --- a/test/export_test.dart +++ b/test/export_test.dart @@ -88,6 +88,7 @@ void main() { druckdauerMinuten: 60, gewichtGramm: 40, abfallGramm: 10, + gesamtpreis: 12.5, filamente: [ const BauteilFilamentModel( filamentId: 'f1', @@ -129,6 +130,7 @@ void main() { await controller.importData(); expect(storage.tables, fixture()); expect(home.materialien, ['PA12', 'PLA']); + expect(home.filamentIdsMitProfil, contains('f1')); expect(home.restgewicht(home.filamente.single), 950); expect(controller.hasError.value, isFalse); }, @@ -174,6 +176,23 @@ void main() { 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 { storage.tables.addAll(fixture()); files.source = BackupModel({for (final key in BackupModel.keys) key: []}) diff --git a/test/responsive_layout_test.dart b/test/responsive_layout_test.dart new file mode 100644 index 0000000..a00b36a --- /dev/null +++ b/test/responsive_layout_test.dart @@ -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(storage); + }); + tearDown(() => Get.reset()); + + Future 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().toggleFilament('filament', true); + } + if (route.startsWith(AnwendungView.routeName)) { + Get.find().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().pageScrollController + : Get.find().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().sonstigesController.text, + 'Entwurf', + ); + } + if (route == BauteilView.routeName) { + expect( + Get.find().selectedFilamente, + contains('filament'), + ); + } + }, + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index ca74163..4e9c750 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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/helper/storage_keys.dart'; @@ -17,6 +18,13 @@ void main() { late HomeController controller; setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Filamentverwaltung', + packageName: 'com.example.ertl_filament_verwaltung', + version: '1.2.3', + buildNumber: '42', + buildSignature: '', + ); Get.testMode = true; Get.put(MemoryStorageService()); controller = Get.put(HomeController()); @@ -46,6 +54,17 @@ void main() { 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 { controller.filamente.add(sample()); await showHome(tester, const Size(1280, 900)); diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee30..a841249 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -51,6 +51,19 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, 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(GetWindowLongPtr(hwnd, GWL_STYLE)), FALSE, + static_cast(GetWindowLongPtr(hwnd, GWL_EXSTYLE)), dpi); + auto* sizes = reinterpret_cast(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. if (flutter_controller_) { std::optional result = diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 5fe4bd0..27cdbfc 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -26,8 +26,8 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"ertl_filament_verwaltung", origin, size)) { + Win32Window::Size size(1400, 768); + if (!window.Create(L"Filament Verwaltung", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);