price per item is printed
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\build_release.ps1" %*
|
||||||
|
exit /b %errorlevel%
|
||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+23
-10
@@ -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,17 +28,27 @@ class HomeView extends GetView<HomeController> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
title: const Row(
|
title: LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) => Row(
|
||||||
Icon(Icons.all_inbox_rounded, color: AppTheme.teal, size: 28),
|
children: [
|
||||||
SizedBox(width: 12),
|
if (constraints.maxWidth >= 260) ...[
|
||||||
Flexible(
|
const Icon(
|
||||||
child: Text(
|
Icons.all_inbox_rounded,
|
||||||
'Filamentverwaltung',
|
color: AppTheme.teal,
|
||||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
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(
|
drawer: Drawer(
|
||||||
@@ -46,6 +57,8 @@ class HomeView extends GetView<HomeController> {
|
|||||||
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'),
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -9,51 +9,58 @@ 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),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 16),
|
children: [
|
||||||
Expanded(
|
const Text(
|
||||||
child: Column(
|
'Bauteilfoto (optional)',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
OutlinedButton.icon(
|
||||||
'Bauteilfoto (optional)',
|
onPressed:
|
||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
controller.isPickingPhoto.value || controller.isSaving.value
|
||||||
),
|
? null
|
||||||
const SizedBox(height: 8),
|
: controller.pickPhoto,
|
||||||
Wrap(
|
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||||
spacing: 8,
|
label: Text(
|
||||||
runSpacing: 8,
|
controller.isPickingPhoto.value
|
||||||
children: [
|
? 'Wird geladen …'
|
||||||
OutlinedButton.icon(
|
: 'Foto auswählen',
|
||||||
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 (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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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).'
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: []})
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user