Compare commits
7 Commits
build-2026
...
25d570c779
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25d570c779 | ||
|
|
df7406b494 | ||
|
|
ab7aa625e6 | ||
|
|
814705cac6 | ||
|
|
3651b073bf | ||
|
|
b553c29d39 | ||
|
|
84d649ac6d |
136
docs/qa/issues-20260224.md
Normal file
136
docs/qa/issues-20260224.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# QA Issues – 2026-02-24
|
||||||
|
|
||||||
|
Scope: Agentische, statische QA-Review (Flutter/Dart CLI lokal nicht verfügbar: `flutter` fehlt). Fokus auf 4-Step-Flow, Save/Load/Delete, JSON-Export und UX-Kantenfälle.
|
||||||
|
|
||||||
|
## Issue 1 (P1) – 4-Step-Flow lässt Fortschritt ohne Pflichtdaten zu
|
||||||
|
**Bereich:** 4-Step-Flow
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Im `Stepper` kann man per `Weiter`/Tap bis Schritt 4 springen, auch ohne Bildauswahl.
|
||||||
|
- Auf Schritt 4 ist „Generate Mosaic“ aktiv, aber `_generate()` bricht stillschweigend ab, wenn kein Bild vorhanden ist.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/main.dart:1068-1127` (`Stepper` ohne Guard/Validation)
|
||||||
|
- `lib/main.dart:1250-1255` (Generate-Button nur an `_isGenerating` gebunden)
|
||||||
|
- `lib/main.dart:884`ff (`_generate()` returnt bei `_sourceImageBytes == null` ohne User-Feedback)
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. App öffnen (kein Bild geladen).
|
||||||
|
2. Im Mosaic-Stepper mehrfach „Weiter“ klicken oder direkt auf Schritt 4 tippen.
|
||||||
|
3. „Generate Mosaic“ klicken.
|
||||||
|
4. Es passiert visuell nichts (kein Ergebnis, kein Hinweis).
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- Schritt-Navigation ist zustandsbasiert: Schritt 2-4 erst zugänglich, wenn notwendige Voraussetzungen erfüllt sind.
|
||||||
|
- „Generate Mosaic“ ist deaktiviert, solange kein Bild geladen ist.
|
||||||
|
- Alternativ/zusätzlich: verständliche Fehlermeldung (Snackbar), falls Generierung ohne Bild ausgelöst wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 2 (P1) – Projekt-Load mit leerem Bild leert bestehenden Zustand nicht
|
||||||
|
**Bereich:** Save/Load-Flow
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Beim Laden werden `_sourceImageBytes` und `_result` nur gesetzt, wenn `data.sourceImageBytes != null`.
|
||||||
|
- Lädt man ein Projekt ohne Bild, bleibt ggf. ein altes Bild/Ergebnis aus vorherigem Zustand erhalten.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/main.dart:308-313` (fehlender `else`-Zweig zum expliziten Zurücksetzen)
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. Projekt A mit Bild laden/generieren.
|
||||||
|
2. Projekt B laden, das ohne Bild gespeichert wurde (oder manuell erstellt wurde).
|
||||||
|
3. Erwartung: leerer/initialer Zustand.
|
||||||
|
4. Ist-Zustand: vorheriges Bild kann bestehen bleiben.
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- Beim Laden eines Projekts ohne `sourceImageBytes` wird Zustand explizit zurückgesetzt (`_sourceImageBytes = null`, `_result = null`, ggf. Step auf 1).
|
||||||
|
- UI zeigt konsistent den tatsächlich geladenen Projektzustand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 3 (P1) – Projekt-Snapshots sind nicht reproduzierbar, da Katalog nicht versioniert wird
|
||||||
|
**Bereich:** Save/Load-Flow + JSON-Export
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Gespeicherte Projekte enthalten Parameter + Bild, aber **keinen Snapshot des verwendeten Farb-Katalogs**.
|
||||||
|
- Beim Laden wird mit aktuellem globalen `_catalog` neu generiert → Ergebnis kann von ursprünglichem Snapshot abweichen.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/project_codec.dart` (`MosaicProjectData` ohne Katalogdaten)
|
||||||
|
- `lib/main.dart:316` (`_generate()` nach Load nutzt aktuellen `_catalog`)
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. Mit Katalog-Set A ein Projekt erzeugen/speichern.
|
||||||
|
2. Katalogfarben ändern (hinzufügen/löschen/umfärben).
|
||||||
|
3. Projekt laden.
|
||||||
|
4. Ergebnisfarben/Zuordnung unterscheiden sich vom ursprünglichen Stand.
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- Projektspeicherung enthält eine Palette/Katalog-Snapshot-Version (mind. Name + Farbe pro Eintrag).
|
||||||
|
- Load nutzt standardmäßig den gespeicherten Snapshot (mit klarer UX bei Konflikten/Optionen).
|
||||||
|
- Reproduzierbarkeit des Mosaiks ist gewährleistet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 4 (P2) – Export JSON nicht klar reproduzierbar ohne Ergebnis
|
||||||
|
**Bereich:** JSON-Export
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Export enthält `project` immer, `result` aber nur falls bereits generiert.
|
||||||
|
- Wenn vor Export keine Generierung lief, fehlt Kerninformation (`assignments`/`palette`) ohne klaren Nutzerhinweis.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/main.dart:378-392`
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. Bild laden, aber nicht generieren.
|
||||||
|
2. „Export JSON“ klicken.
|
||||||
|
3. Exportdatei enthält kein `result`-Objekt.
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- UX-Entscheidung explizit umsetzen:
|
||||||
|
- entweder Export-Button nur mit vorhandenem Ergebnis aktivieren, oder
|
||||||
|
- vor Export automatisch generieren, oder
|
||||||
|
- deutlichen Hinweisdialog anzeigen („Export ohne Ergebnisdaten“).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 5 (P3) – „Fertig“-CTA im letzten Step ohne klaren Effekt
|
||||||
|
**Bereich:** 4-Step-Flow UX
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Im letzten Step zeigt der Continue-Button „Fertig“, führt aber funktional zu keiner sichtbaren Aktion.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/main.dart:1071-1093` (`onStepContinue` erhöht nur bis max. letztem Step)
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. Bis Schritt 4 navigieren.
|
||||||
|
2. „Fertig“ klicken.
|
||||||
|
3. Kein Abschluss-Feedback/State-Change.
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- Letzter CTA hat klare Bedeutung (z. B. „Generieren“, „Abschließen“, „Zum Export“) oder wird im letzten Step ausgeblendet.
|
||||||
|
- Nutzer erhält eindeutiges Abschlussfeedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 6 (P3) – Lösch-Flow ohne Hinweis auf betroffenen Arbeitsstand
|
||||||
|
**Bereich:** Delete-Flow UX
|
||||||
|
|
||||||
|
**Beobachtung**
|
||||||
|
- Beim Löschen eines Snapshots gibt es nur Dateiname + Bestätigung.
|
||||||
|
- Kein Hinweis, ob gerade geladener Stand betroffen ist bzw. wie sich das auf „Letzten Stand laden“ auswirkt.
|
||||||
|
|
||||||
|
**Code-Hinweis**
|
||||||
|
- `lib/main.dart:333-367`
|
||||||
|
|
||||||
|
**Repro-Schritte**
|
||||||
|
1. Snapshot laden.
|
||||||
|
2. In Projekte denselben Snapshot löschen.
|
||||||
|
3. Nutzer bleibt ohne Kontext, ob aktiver Stand/Latest-Verhalten beeinflusst ist.
|
||||||
|
|
||||||
|
**Akzeptanzkriterium**
|
||||||
|
- Dialog/Feedback benennt Auswirkungen klar (z. B. „Aktuell geladener Zustand bleibt im Speicher bis Wechsel/Neustart“).
|
||||||
|
- Optional: Markierung des aktuell geladenen Snapshots in der Liste.
|
||||||
52
docs/review/review-20260224.md
Normal file
52
docs/review/review-20260224.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Code Review – Änderungen von `814705c` bis `HEAD` (`ab7aa62`)
|
||||||
|
|
||||||
|
## 1) High-risk Findings
|
||||||
|
|
||||||
|
### Keine blocker-kritischen Defekte gefunden
|
||||||
|
Ich habe im betrachteten Commit keine eindeutigen Crash-/Datenverlust-Blocker gefunden, die einen sofortigen Release-Stopp erzwingen.
|
||||||
|
|
||||||
|
> Hinweis: Es gibt aber eine **relevante funktionale Regression** (siehe Nitpicks #1), die je nach Produktanforderung als Go/No-Go-Kriterium gewertet werden kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Nitpicks / Verbesserungen
|
||||||
|
|
||||||
|
1. **Funktionale Regression in der UI (ehemals 4 Style-Regler, jetzt nur noch 1 sichtbar)**
|
||||||
|
In `lib/main.dart` zeigt `_buildColorStep()` nur noch den Slider `fidelityStructure`.
|
||||||
|
Die bisherigen Feineinstellungen `ditheringStrength`, `edgeEmphasis`, `colorVariation` sind weiter im State/Codec vorhanden, aber nicht mehr direkt im UI editierbar.
|
||||||
|
**Impact:** Nutzer verlieren granulare Qualitätskontrolle; gespeicherte Werte bleiben zwar erhalten, sind aber kaum noch aktiv manipulierbar.
|
||||||
|
|
||||||
|
2. **Stepper-Logik ohne Validierung / Guidance**
|
||||||
|
`onStepContinue` lässt das Voranschreiten ohne notwendige Preconditions zu (z. B. ohne Bildauswahl bis Schritt 4). Das ist technisch robust (Generate guarded), aber UX-seitig irreführend.
|
||||||
|
**Vorschlag:** Continue-Button je Schritt konditional deaktivieren oder klare Inline-Hinweise anzeigen.
|
||||||
|
|
||||||
|
3. **`Fertig`-Button im letzten Step ohne echte Aktion**
|
||||||
|
In `controlsBuilder` wird im letzten Schritt der Label-Text auf `Fertig` gesetzt, aber `onStepContinue` führt dort effektiv nichts mehr aus.
|
||||||
|
**Vorschlag:** Auf letzter Stufe Button ausblenden oder mit sinnvoller Aktion belegen (z. B. Export, Speichern, Zur Projektliste).
|
||||||
|
|
||||||
|
4. **Projekt-Ladesemantik setzt immer auf Step `result`**
|
||||||
|
Beim Laden mit Bild springt der Flow direkt auf `MosaicFlowStep.result`. Das ist für „Quick resume“ okay, nimmt aber ggf. den Guided-Flow-Charakter.
|
||||||
|
**Vorschlag:** Optionales Verhalten (z. B. Restore des letzten Steps oder Konfiguration `resumeAtResult`).
|
||||||
|
|
||||||
|
5. **Testabdeckung für neue Kernpfade noch dünn**
|
||||||
|
Es gibt gute Basis-Tests (`project_codec_test`, einfacher Stepper-Smoke-Test), aber es fehlen Tests für:
|
||||||
|
- latest/autosave-Verhalten (`latest_project.json`),
|
||||||
|
- manuell vs. automatisch speichern,
|
||||||
|
- Export-JSON-Struktur inkl. Result-Payload,
|
||||||
|
- Laden aus Snapshot vs. latest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Go/No-Go Empfehlung
|
||||||
|
|
||||||
|
**Empfehlung: GO mit Auflagen (kein Hard No-Go).**
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
- Die neuen Features (4-Step-Flow, autosave/latest, JSON-Export, Bestätigungsdialog beim Löschen) sind grundsätzlich sinnvoll umgesetzt.
|
||||||
|
- Kein klarer, reproduzierbarer Blocker im Diff erkennbar.
|
||||||
|
- Vor Release sollten jedoch mindestens die UX-/Funktionsregressionspunkte (insb. fehlende 3 Style-Regler) bewusst entschieden/fixiert werden.
|
||||||
|
|
||||||
|
**Release-Auflagen (kurz):**
|
||||||
|
1. Entscheiden/fixen, ob die 3 fehlenden Style-Regler absichtliche Scope-Reduktion oder Regression sind.
|
||||||
|
2. Stepper-UX (Continue/Finish-Verhalten) konsistenter machen.
|
||||||
|
3. 2–3 gezielte Tests für Save/Load/Export-Pfade ergänzen.
|
||||||
690
lib/main.dart
690
lib/main.dart
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:korken_mosaic/project_codec.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -62,7 +63,8 @@ class KorkenMosaicApp extends StatelessWidget {
|
|||||||
indicatorColor: const Color(0x804FD6E8),
|
indicatorColor: const Color(0x804FD6E8),
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.76),
|
backgroundColor: Colors.white.withValues(alpha: 0.76),
|
||||||
labelTextStyle: WidgetStatePropertyAll(
|
labelTextStyle: WidgetStatePropertyAll(
|
||||||
TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
TextStyle(
|
||||||
|
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
sliderTheme: SliderThemeData(
|
sliderTheme: SliderThemeData(
|
||||||
@@ -83,7 +85,9 @@ enum CatalogViewMode { list, grid }
|
|||||||
|
|
||||||
enum ColorExtractionMode { dominant, average }
|
enum ColorExtractionMode { dominant, average }
|
||||||
|
|
||||||
enum HomeSection { mosaic, catalog }
|
enum HomeSection { mosaic, catalog, projects }
|
||||||
|
|
||||||
|
enum MosaicFlowStep { image, size, colors, result }
|
||||||
|
|
||||||
class MosaicHomePage extends StatefulWidget {
|
class MosaicHomePage extends StatefulWidget {
|
||||||
const MosaicHomePage({super.key});
|
const MosaicHomePage({super.key});
|
||||||
@@ -114,6 +118,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
||||||
bool _isCaptureFlowInProgress = false;
|
bool _isCaptureFlowInProgress = false;
|
||||||
bool _isRecoveringCapture = false;
|
bool _isRecoveringCapture = false;
|
||||||
|
bool _isProjectBusy = false;
|
||||||
|
List<File> _projectFiles = [];
|
||||||
|
MosaicFlowStep _currentFlowStep = MosaicFlowStep.image;
|
||||||
|
|
||||||
double _fidelityStructure = 0.5;
|
double _fidelityStructure = 0.5;
|
||||||
double _ditheringStrength = 0.35;
|
double _ditheringStrength = 0.35;
|
||||||
@@ -131,8 +138,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
||||||
_capSizeCtrl.addListener(_scheduleRegenerate);
|
_capSizeCtrl.addListener(_scheduleRegenerate);
|
||||||
_loadCatalog();
|
_loadCatalog();
|
||||||
|
_refreshProjectFiles();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_recoverCaptureOnResumeOrStart();
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
_loadProject(silent: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +149,12 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_recoverCaptureOnResumeOrStart();
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
_loadProject(silent: true);
|
||||||
|
}
|
||||||
|
if (state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +183,226 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
return file.path;
|
return file.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Directory> _projectsDir() async {
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory('${docs.path}/projects');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _projectFilename() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final stamp = now
|
||||||
|
.toIso8601String()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replaceAll('.', '-')
|
||||||
|
.replaceAll('T', '_');
|
||||||
|
return 'project_$stamp.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _latestProjectFile() async {
|
||||||
|
final projectsDir = await _projectsDir();
|
||||||
|
return File('${projectsDir.path}/latest_project.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
MosaicProjectData _buildProjectData() {
|
||||||
|
return MosaicProjectData(
|
||||||
|
useCapSize: _useCapSize,
|
||||||
|
gridWidth: _gridWidthCtrl.text,
|
||||||
|
gridHeight: _gridHeightCtrl.text,
|
||||||
|
capSize: _capSizeCtrl.text,
|
||||||
|
fidelityStructure: _fidelityStructure,
|
||||||
|
ditheringStrength: _ditheringStrength,
|
||||||
|
edgeEmphasis: _edgeEmphasis,
|
||||||
|
colorVariation: _colorVariation,
|
||||||
|
selectedPreset: _selectedPreset.name,
|
||||||
|
sourceImageBytes: _sourceImageBytes,
|
||||||
|
savedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshProjectFiles() async {
|
||||||
|
try {
|
||||||
|
final dir = await _projectsDir();
|
||||||
|
final files = dir
|
||||||
|
.listSync()
|
||||||
|
.whereType<File>()
|
||||||
|
.where((f) => f.path.endsWith('.json') && !f.path.endsWith('latest_project.json'))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.path.compareTo(a.path));
|
||||||
|
if (mounted) setState(() => _projectFiles = files);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProject({bool silent = false, bool manual = true}) async {
|
||||||
|
if (_isProjectBusy) return;
|
||||||
|
_isProjectBusy = true;
|
||||||
|
try {
|
||||||
|
final payload = _buildProjectData();
|
||||||
|
final latestFile = await _latestProjectFile();
|
||||||
|
await latestFile.writeAsString(jsonEncode(payload.toJson()), flush: true);
|
||||||
|
|
||||||
|
if (manual) {
|
||||||
|
final projectsDir = await _projectsDir();
|
||||||
|
final snapshot = File('${projectsDir.path}/${_projectFilename()}');
|
||||||
|
await snapshot.writeAsString(jsonEncode(payload.toJson()), flush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _refreshProjectFiles();
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(manual
|
||||||
|
? 'Projekt gespeichert ✅'
|
||||||
|
: 'Projekt automatisch gesichert')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Speichern fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProjectBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProject({bool silent = false, File? fromFile}) async {
|
||||||
|
if (_isProjectBusy) return;
|
||||||
|
_isProjectBusy = true;
|
||||||
|
try {
|
||||||
|
File? file = fromFile;
|
||||||
|
if (file == null) {
|
||||||
|
final latest = await _latestProjectFile();
|
||||||
|
if (await latest.exists()) {
|
||||||
|
file = latest;
|
||||||
|
} else {
|
||||||
|
await _refreshProjectFiles();
|
||||||
|
if (_projectFiles.isEmpty) return;
|
||||||
|
file = _projectFiles.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
|
||||||
|
final data = MosaicProjectData.fromJson(
|
||||||
|
jsonDecode(await file.readAsString()) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_useCapSize = data.useCapSize;
|
||||||
|
_gridWidthCtrl.text = data.gridWidth;
|
||||||
|
_gridHeightCtrl.text = data.gridHeight;
|
||||||
|
_capSizeCtrl.text = data.capSize;
|
||||||
|
_fidelityStructure = data.fidelityStructure;
|
||||||
|
_ditheringStrength = data.ditheringStrength;
|
||||||
|
_edgeEmphasis = data.edgeEmphasis;
|
||||||
|
_colorVariation = data.colorVariation;
|
||||||
|
|
||||||
|
_selectedPreset = StylePreset.values.firstWhere(
|
||||||
|
(p) => p.name == data.selectedPreset,
|
||||||
|
orElse: () => StylePreset.ausgewogen,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.sourceImageBytes != null) {
|
||||||
|
_sourceImageBytes = data.sourceImageBytes;
|
||||||
|
_result = null;
|
||||||
|
_currentFlowStep = MosaicFlowStep.result;
|
||||||
|
}
|
||||||
|
_activeSection = HomeSection.mosaic;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.sourceImageBytes != null) await _generate();
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Projekt geladen ✅')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Laden fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProjectBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteProject(File file) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Projekt löschen?'),
|
||||||
|
content: Text(file.path.split('/').last),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Abbrechen')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Löschen')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await file.delete();
|
||||||
|
await _refreshProjectFiles();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Projekt gelöscht')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Löschen fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportProjectJson() async {
|
||||||
|
if (_sourceImageBytes == null) return;
|
||||||
|
try {
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory('${docs.path}/exports');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final now = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||||
|
final file = File('${dir.path}/mosaic_export_$now.json');
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
'project': _buildProjectData().toJson(),
|
||||||
|
if (_result != null)
|
||||||
|
'result': {
|
||||||
|
'width': _result!.width,
|
||||||
|
'height': _result!.height,
|
||||||
|
'assignments': _result!.assignments,
|
||||||
|
'palette': _result!.palette
|
||||||
|
.map((c) => {
|
||||||
|
'name': c.name,
|
||||||
|
'value': c.color.toARGB32(),
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await file.writeAsString(jsonEncode(payload), flush: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('JSON exportiert: ${file.path}')),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Export fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCatalog() async {
|
Future<void> _loadCatalog() async {
|
||||||
final defaults = [
|
final defaults = [
|
||||||
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
||||||
@@ -210,7 +445,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sourceImageBytes = bytes;
|
_sourceImageBytes = bytes;
|
||||||
_result = null;
|
_result = null;
|
||||||
|
_currentFlowStep = MosaicFlowStep.size;
|
||||||
});
|
});
|
||||||
|
await _saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _pendingCaptureFile() async {
|
Future<File> _pendingCaptureFile() async {
|
||||||
@@ -318,6 +555,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
detected = {
|
detected = {
|
||||||
'dominantColor': fallbackColor,
|
'dominantColor': fallbackColor,
|
||||||
'averageColor': fallbackColor,
|
'averageColor': fallbackColor,
|
||||||
|
'topDominantColors': [fallbackColor],
|
||||||
'usedFallback': true,
|
'usedFallback': true,
|
||||||
'circleX': imageW / 2,
|
'circleX': imageW / 2,
|
||||||
'circleY': imageH / 2,
|
'circleY': imageH / 2,
|
||||||
@@ -695,6 +933,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
);
|
);
|
||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
});
|
});
|
||||||
|
await _saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -705,7 +944,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
backgroundColor: Colors.white.withValues(alpha: 0.45),
|
backgroundColor: Colors.white.withValues(alpha: 0.45),
|
||||||
title: Text(_activeSection == HomeSection.mosaic
|
title: Text(_activeSection == HomeSection.mosaic
|
||||||
? 'Bottle-Cap Mosaic Studio'
|
? 'Bottle-Cap Mosaic Studio'
|
||||||
: 'Cap Catalog'),
|
: _activeSection == HomeSection.catalog
|
||||||
|
? 'Cap Catalog'
|
||||||
|
: 'Projekte'),
|
||||||
),
|
),
|
||||||
floatingActionButton: _activeSection == HomeSection.mosaic
|
floatingActionButton: _activeSection == HomeSection.mosaic
|
||||||
? FloatingActionButton.extended(
|
? FloatingActionButton.extended(
|
||||||
@@ -728,7 +969,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _activeSection == HomeSection.mosaic
|
: _activeSection == HomeSection.mosaic
|
||||||
? _buildMosaicScreen()
|
? _buildMosaicScreen()
|
||||||
: _buildCatalogScreen(),
|
: _activeSection == HomeSection.catalog
|
||||||
|
? _buildCatalogScreen()
|
||||||
|
: _buildProjectsScreen(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
@@ -747,35 +990,184 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
selectedIcon: Icon(Icons.inventory_2),
|
selectedIcon: Icon(Icons.inventory_2),
|
||||||
label: 'Catalog',
|
label: 'Catalog',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.folder_copy_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder_copy),
|
||||||
|
label: 'Projekte',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildProjectsScreen() {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
children: [
|
||||||
|
_GlassCard(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _loadProject(),
|
||||||
|
icon: const Icon(Icons.history),
|
||||||
|
label: const Text('Letzten Stand laden'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _refreshProjectFiles,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Aktualisieren'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_projectFiles.isEmpty)
|
||||||
|
const Center(child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('Noch keine gespeicherten Snapshots.'),
|
||||||
|
))
|
||||||
|
else
|
||||||
|
..._projectFiles.map((file) {
|
||||||
|
final name = file.uri.pathSegments.isEmpty
|
||||||
|
? file.path
|
||||||
|
: file.uri.pathSegments.last;
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||||
|
title: Text(name),
|
||||||
|
trailing: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Laden',
|
||||||
|
icon: const Icon(Icons.playlist_add_check_circle_outlined),
|
||||||
|
onPressed: () => _loadProject(fromFile: file),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Löschen',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _deleteProject(file),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMosaicScreen() {
|
Widget _buildMosaicScreen() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
_GlassCard(
|
_GlassCard(
|
||||||
child: Wrap(
|
child: Stepper(
|
||||||
runSpacing: 10,
|
currentStep: _currentFlowStep.index,
|
||||||
spacing: 10,
|
controlsBuilder: (context, details) {
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
final isLast = _currentFlowStep == MosaicFlowStep.result;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: details.onStepContinue,
|
||||||
|
child: Text(isLast ? 'Fertig' : 'Weiter'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_currentFlowStep.index > 0)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: details.onStepCancel,
|
||||||
|
child: const Text('Zurück'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onStepContinue: () {
|
||||||
|
setState(() {
|
||||||
|
final next = _currentFlowStep.index + 1;
|
||||||
|
if (next <= MosaicFlowStep.result.index) {
|
||||||
|
_currentFlowStep = MosaicFlowStep.values[next];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStepCancel: () {
|
||||||
|
setState(() {
|
||||||
|
final prev = _currentFlowStep.index - 1;
|
||||||
|
if (prev >= 0) {
|
||||||
|
_currentFlowStep = MosaicFlowStep.values[prev];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStepTapped: (index) {
|
||||||
|
setState(() => _currentFlowStep = MosaicFlowStep.values[index]);
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
Step(
|
||||||
|
title: const Text('1) Bild'),
|
||||||
|
isActive: _currentFlowStep.index >= 0,
|
||||||
|
content: _buildImageStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('2) Größe'),
|
||||||
|
isActive: _currentFlowStep.index >= 1,
|
||||||
|
content: _buildSizeStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('3) Farben'),
|
||||||
|
isActive: _currentFlowStep.index >= 2,
|
||||||
|
content: _buildColorStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('4) Ergebnis'),
|
||||||
|
isActive: _currentFlowStep.index >= 3,
|
||||||
|
content: _buildResultStep(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageStep() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
|
key: const Key('pick-image-btn'),
|
||||||
onPressed: _pickImage,
|
onPressed: _pickImage,
|
||||||
icon: const Icon(Icons.image_outlined),
|
icon: const Icon(Icons.image_outlined),
|
||||||
label: const Text('Import target image'),
|
label: const Text('Import target image'),
|
||||||
),
|
),
|
||||||
if (_sourceImageBytes != null)
|
OutlinedButton.icon(
|
||||||
const Chip(label: Text('Image loaded ✅')),
|
onPressed: () => _saveProject(),
|
||||||
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: const Text('Speichern'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _loadProject(),
|
||||||
|
icon: const Icon(Icons.folder_open_outlined),
|
||||||
|
label: const Text('Laden'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
if (_sourceImageBytes != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
_GlassCard(
|
const Chip(label: Text('Image loaded ✅')),
|
||||||
child: Column(
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSizeStep() {
|
||||||
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<bool>(
|
SegmentedButton<bool>(
|
||||||
@@ -789,7 +1181,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
_scheduleRegenerate();
|
_scheduleRegenerate();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
if (!_useCapSize)
|
if (!_useCapSize)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -797,16 +1189,14 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _gridWidthCtrl,
|
controller: _gridWidthCtrl,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration:
|
decoration: const InputDecoration(labelText: 'Grid Width')),
|
||||||
const InputDecoration(labelText: 'Grid Width')),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _gridHeightCtrl,
|
controller: _gridHeightCtrl,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration:
|
decoration: const InputDecoration(labelText: 'Grid Height')),
|
||||||
const InputDecoration(labelText: 'Grid Height')),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -818,11 +1208,11 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
labelText: 'Approx cap size in source image (pixels)'),
|
labelText: 'Approx cap size in source image (pixels)'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
const SizedBox(height: 12),
|
|
||||||
_GlassCard(
|
Widget _buildColorStep() {
|
||||||
child: Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Style Preset', style: Theme.of(context).textTheme.titleMedium),
|
Text('Style Preset', style: Theme.of(context).textTheme.titleMedium),
|
||||||
@@ -836,7 +1226,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
selected: {_selectedPreset},
|
selected: {_selectedPreset},
|
||||||
onSelectionChanged: (s) => _applyPreset(s.first),
|
onSelectionChanged: (s) => _applyPreset(s.first),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
Text('Aktive Katalogfarben: ${_catalog.length}'),
|
||||||
_SliderRow(
|
_SliderRow(
|
||||||
label: 'Fidelity ↔ Structure',
|
label: 'Fidelity ↔ Structure',
|
||||||
leftLabel: 'Fidelity',
|
leftLabel: 'Fidelity',
|
||||||
@@ -846,67 +1236,38 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
setState(() => _fidelityStructure = v);
|
setState(() => _fidelityStructure = v);
|
||||||
_onStyleChanged();
|
_onStyleChanged();
|
||||||
}),
|
}),
|
||||||
_SliderRow(
|
|
||||||
label: 'Dithering strength',
|
|
||||||
leftLabel: 'Off',
|
|
||||||
rightLabel: 'Strong',
|
|
||||||
value: _ditheringStrength,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _ditheringStrength = v);
|
|
||||||
_onStyleChanged();
|
|
||||||
}),
|
|
||||||
_SliderRow(
|
|
||||||
label: 'Edge emphasis',
|
|
||||||
leftLabel: 'Soft',
|
|
||||||
rightLabel: 'Crisp',
|
|
||||||
value: _edgeEmphasis,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _edgeEmphasis = v);
|
|
||||||
_onStyleChanged();
|
|
||||||
}),
|
|
||||||
_SliderRow(
|
|
||||||
label: 'Color tolerance / variation',
|
|
||||||
leftLabel: 'Strict',
|
|
||||||
rightLabel: 'Varied',
|
|
||||||
value: _colorVariation,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _colorVariation = v);
|
|
||||||
_onStyleChanged();
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
const SizedBox(height: 14),
|
|
||||||
if (_result != null) ...[
|
Widget _buildResultStep() {
|
||||||
_GlassCard(
|
return Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Preview (${_result!.width} x ${_result!.height})', style: Theme.of(context).textTheme.titleMedium),
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('generate-btn'),
|
||||||
|
onPressed: _isGenerating ? null : _generate,
|
||||||
|
icon: const Icon(Icons.auto_fix_high_rounded),
|
||||||
|
label: const Text('Generate Mosaic'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _exportProjectJson,
|
||||||
|
icon: const Icon(Icons.file_download_outlined),
|
||||||
|
label: const Text('Export JSON'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ClipRRect(
|
if (_result != null) ...[
|
||||||
borderRadius: BorderRadius.circular(18),
|
Text('Preview (${_result!.width} x ${_result!.height})'),
|
||||||
child: RepaintBoundary(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: _result!.width / _result!.height,
|
|
||||||
child: Image.memory(_result!.previewPng,
|
|
||||||
fit: BoxFit.fill,
|
|
||||||
filterQuality: FilterQuality.none,
|
|
||||||
gaplessPlayback: true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_GlassCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Bill of Materials', style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
..._result!.sortedCounts.map(
|
Image.memory(_result!.previewPng, gaplessPlayback: true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._result!.sortedCounts.take(8).map(
|
||||||
(e) => ListTile(
|
(e) => ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: CircleAvatar(backgroundColor: e.key.color),
|
leading: CircleAvatar(backgroundColor: e.key.color),
|
||||||
@@ -914,12 +1275,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
trailing: Text('${e.value} caps'),
|
trailing: Text('${e.value} caps'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,7 +1373,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
itemCount: _catalog.length,
|
itemCount: _catalog.length,
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
childAspectRatio: 1.2,
|
// Mehr vertikaler Platz pro Card, damit Name/Hex/Delete nicht überlaufen.
|
||||||
|
childAspectRatio: 0.92,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
@@ -1051,10 +1409,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints.tightFor(
|
||||||
|
width: 30, height: 30),
|
||||||
onPressed: _catalog.length <= 1
|
onPressed: _catalog.length <= 1
|
||||||
? null
|
? null
|
||||||
: () => _deleteEntry(entry),
|
: () => _deleteEntry(entry),
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline, size: 20),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1119,6 +1480,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
|
|
||||||
late Color _dominantColor;
|
late Color _dominantColor;
|
||||||
late Color _averageColor;
|
late Color _averageColor;
|
||||||
|
late List<Color> _topDominantColors;
|
||||||
late Uint8List _previewBytes;
|
late Uint8List _previewBytes;
|
||||||
late bool _usedFallback;
|
late bool _usedFallback;
|
||||||
late double _imageW;
|
late double _imageW;
|
||||||
@@ -1139,6 +1501,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
final detected = widget.detected;
|
final detected = widget.detected;
|
||||||
_dominantColor = Color(detected['dominantColor'] as int);
|
_dominantColor = Color(detected['dominantColor'] as int);
|
||||||
_averageColor = Color(detected['averageColor'] as int);
|
_averageColor = Color(detected['averageColor'] as int);
|
||||||
|
_topDominantColors = ((detected['topDominantColors'] as List?) ??
|
||||||
|
[detected['dominantColor']])
|
||||||
|
.map((e) => Color(e as int))
|
||||||
|
.toList(growable: false);
|
||||||
_previewBytes = detected['previewPng'] as Uint8List;
|
_previewBytes = detected['previewPng'] as Uint8List;
|
||||||
_usedFallback = detected['usedFallback'] as bool? ?? false;
|
_usedFallback = detected['usedFallback'] as bool? ?? false;
|
||||||
_imageW = (detected['imageW'] as num).toDouble();
|
_imageW = (detected['imageW'] as num).toDouble();
|
||||||
@@ -1214,6 +1580,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
if (localToken != _recalcToken) return;
|
if (localToken != _recalcToken) return;
|
||||||
_dominantColor = Color(adjusted['dominantColor'] as int);
|
_dominantColor = Color(adjusted['dominantColor'] as int);
|
||||||
_averageColor = Color(adjusted['averageColor'] as int);
|
_averageColor = Color(adjusted['averageColor'] as int);
|
||||||
|
_topDominantColors = ((adjusted['topDominantColors'] as List?) ??
|
||||||
|
[adjusted['dominantColor']])
|
||||||
|
.map((e) => Color(e as int))
|
||||||
|
.toList(growable: false);
|
||||||
_previewBytes = adjusted['previewPng'] as Uint8List;
|
_previewBytes = adjusted['previewPng'] as Uint8List;
|
||||||
_selected = _mode == ColorExtractionMode.dominant
|
_selected = _mode == ColorExtractionMode.dominant
|
||||||
? _dominantColor
|
? _dominantColor
|
||||||
@@ -1227,6 +1597,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
const fallback = Colors.orange;
|
const fallback = Colors.orange;
|
||||||
_dominantColor = fallback;
|
_dominantColor = fallback;
|
||||||
_averageColor = fallback;
|
_averageColor = fallback;
|
||||||
|
_topDominantColors = [fallback];
|
||||||
_selected = fallback;
|
_selected = fallback;
|
||||||
_hexCtrl.text = _colorToHexStatic(_selected);
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1678,39 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Dominante Farben (Anteil) + Durchschnitt:',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _topDominantColors.length; i++)
|
||||||
|
_ColorPickChip(
|
||||||
|
label: 'D${i + 1}',
|
||||||
|
color: _topDominantColors[i],
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selected = _topDominantColors[i];
|
||||||
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ColorPickChip(
|
||||||
|
label: 'Mix',
|
||||||
|
color: _averageColor,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selected = _averageColor;
|
||||||
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: _circleR,
|
value: _circleR,
|
||||||
min: 0.08,
|
min: 0.08,
|
||||||
@@ -1397,6 +1801,50 @@ String _colorToHexStatic(Color color) {
|
|||||||
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ColorPickChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ColorPickChip({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.black12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: Colors.black26),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AeroBackgroundAccents extends StatelessWidget {
|
class _AeroBackgroundAccents extends StatelessWidget {
|
||||||
const _AeroBackgroundAccents();
|
const _AeroBackgroundAccents();
|
||||||
|
|
||||||
@@ -1465,7 +1913,8 @@ class _GlassCard extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final EdgeInsetsGeometry padding;
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
const _GlassCard({required this.child, this.padding = const EdgeInsets.all(12)});
|
const _GlassCard(
|
||||||
|
{required this.child, this.padding = const EdgeInsets.all(12)});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2014,9 +2463,11 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
|
|||||||
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
||||||
final decoded = img.decodeImage(sourceBytes);
|
final decoded = img.decodeImage(sourceBytes);
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
|
final fallback = Colors.orange.toARGB32();
|
||||||
return {
|
return {
|
||||||
'dominantColor': Colors.orange.toARGB32(),
|
'dominantColor': fallback,
|
||||||
'averageColor': Colors.orange.toARGB32(),
|
'averageColor': fallback,
|
||||||
|
'topDominantColors': [fallback],
|
||||||
'usedFallback': true,
|
'usedFallback': true,
|
||||||
'circleX': 0.5,
|
'circleX': 0.5,
|
||||||
'circleY': 0.5,
|
'circleY': 0.5,
|
||||||
@@ -2061,6 +2512,7 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
|||||||
return {
|
return {
|
||||||
'dominantColor': stats.dominantArgb,
|
'dominantColor': stats.dominantArgb,
|
||||||
'averageColor': stats.averageArgb,
|
'averageColor': stats.averageArgb,
|
||||||
|
'topDominantColors': stats.topDominantArgbs,
|
||||||
'usedFallback': usedFallback,
|
'usedFallback': usedFallback,
|
||||||
'circleX': upscaledCircle.cx,
|
'circleX': upscaledCircle.cx,
|
||||||
'circleY': upscaledCircle.cy,
|
'circleY': upscaledCircle.cy,
|
||||||
@@ -2076,9 +2528,11 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|||||||
final sourceBytes = request['sourceBytes'] as Uint8List;
|
final sourceBytes = request['sourceBytes'] as Uint8List;
|
||||||
final decoded = img.decodeImage(sourceBytes);
|
final decoded = img.decodeImage(sourceBytes);
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
|
final fallback = Colors.orange.toARGB32();
|
||||||
return {
|
return {
|
||||||
'dominantColor': Colors.orange.toARGB32(),
|
'dominantColor': fallback,
|
||||||
'averageColor': Colors.orange.toARGB32(),
|
'averageColor': fallback,
|
||||||
|
'topDominantColors': [fallback],
|
||||||
'previewPng': Uint8List.fromList(
|
'previewPng': Uint8List.fromList(
|
||||||
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
||||||
};
|
};
|
||||||
@@ -2124,6 +2578,7 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|||||||
return {
|
return {
|
||||||
'dominantColor': stats.dominantArgb,
|
'dominantColor': stats.dominantArgb,
|
||||||
'averageColor': stats.averageArgb,
|
'averageColor': stats.averageArgb,
|
||||||
|
'topDominantColors': stats.topDominantArgbs,
|
||||||
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2273,7 +2728,11 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
|||||||
|
|
||||||
if (samples.isEmpty) {
|
if (samples.isEmpty) {
|
||||||
final fallback = const Color(0xFFFF9800).toARGB32();
|
final fallback = const Color(0xFFFF9800).toARGB32();
|
||||||
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
|
return _CapColorStats(
|
||||||
|
averageArgb: fallback,
|
||||||
|
dominantArgb: fallback,
|
||||||
|
topDominantArgbs: [fallback],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final lumValues = samples.map((s) => s.luminance).toList()..sort();
|
final lumValues = samples.map((s) => s.luminance).toList()..sort();
|
||||||
@@ -2289,9 +2748,14 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
|||||||
final usable = filtered.isEmpty ? samples : filtered;
|
final usable = filtered.isEmpty ? samples : filtered;
|
||||||
|
|
||||||
final avg = _weightedAverage(usable);
|
final avg = _weightedAverage(usable);
|
||||||
final dominant = _weightedDominant(usable);
|
final topDominant = _weightedDominantTop(usable, 3);
|
||||||
|
final dominant = topDominant.first;
|
||||||
|
|
||||||
return _CapColorStats(averageArgb: avg, dominantArgb: dominant);
|
return _CapColorStats(
|
||||||
|
averageArgb: avg,
|
||||||
|
dominantArgb: dominant,
|
||||||
|
topDominantArgbs: topDominant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCircle(img.Image image, double cx, double cy, double r) {
|
void _drawCircle(img.Image image, double cx, double cy, double r) {
|
||||||
@@ -2327,8 +2791,13 @@ class _DetectedCircle {
|
|||||||
class _CapColorStats {
|
class _CapColorStats {
|
||||||
final int averageArgb;
|
final int averageArgb;
|
||||||
final int dominantArgb;
|
final int dominantArgb;
|
||||||
|
final List<int> topDominantArgbs;
|
||||||
|
|
||||||
const _CapColorStats({required this.averageArgb, required this.dominantArgb});
|
const _CapColorStats({
|
||||||
|
required this.averageArgb,
|
||||||
|
required this.dominantArgb,
|
||||||
|
required this.topDominantArgbs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ColorSample {
|
class _ColorSample {
|
||||||
@@ -2389,7 +2858,7 @@ int _weightedAverage(List<_ColorSample> samples) {
|
|||||||
).toARGB32();
|
).toARGB32();
|
||||||
}
|
}
|
||||||
|
|
||||||
int _weightedDominant(List<_ColorSample> samples) {
|
List<int> _weightedDominantTop(List<_ColorSample> samples, int count) {
|
||||||
final bins = <int, _WeightedRgb>{};
|
final bins = <int, _WeightedRgb>{};
|
||||||
for (final s in samples) {
|
for (final s in samples) {
|
||||||
final hsv = _rgbToHsv(s.r, s.g, s.b);
|
final hsv = _rgbToHsv(s.r, s.g, s.b);
|
||||||
@@ -2401,18 +2870,25 @@ int _weightedDominant(List<_ColorSample> samples) {
|
|||||||
bucket.add(s, s.weight);
|
bucket.add(s, s.weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
_WeightedRgb? best;
|
if (bins.isEmpty) return [_weightedAverage(samples)];
|
||||||
for (final b in bins.values) {
|
|
||||||
if (best == null || b.weight > best.weight) best = b;
|
|
||||||
}
|
|
||||||
if (best == null || best.weight <= 0) return _weightedAverage(samples);
|
|
||||||
|
|
||||||
|
final sorted = bins.values.toList()
|
||||||
|
..sort((a, b) => b.weight.compareTo(a.weight));
|
||||||
|
|
||||||
|
final take = math.max(1, math.min(count, sorted.length));
|
||||||
|
return sorted.take(take).map((b) {
|
||||||
|
if (b.weight <= 0) return _weightedAverage(samples);
|
||||||
return Color.fromARGB(
|
return Color.fromARGB(
|
||||||
255,
|
255,
|
||||||
(best.r / best.weight).round().clamp(0, 255),
|
(b.r / b.weight).round().clamp(0, 255),
|
||||||
(best.g / best.weight).round().clamp(0, 255),
|
(b.g / b.weight).round().clamp(0, 255),
|
||||||
(best.b / best.weight).round().clamp(0, 255),
|
(b.b / b.weight).round().clamp(0, 255),
|
||||||
).toARGB32();
|
).toARGB32();
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _weightedDominant(List<_ColorSample> samples) {
|
||||||
|
return _weightedDominantTop(samples, 1).first;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> _rgbToHsv(int r, int g, int b) {
|
List<double> _rgbToHsv(int r, int g, int b) {
|
||||||
|
|||||||
62
lib/project_codec.dart
Normal file
62
lib/project_codec.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class MosaicProjectData {
|
||||||
|
final bool useCapSize;
|
||||||
|
final String gridWidth;
|
||||||
|
final String gridHeight;
|
||||||
|
final String capSize;
|
||||||
|
final double fidelityStructure;
|
||||||
|
final double ditheringStrength;
|
||||||
|
final double edgeEmphasis;
|
||||||
|
final double colorVariation;
|
||||||
|
final String selectedPreset;
|
||||||
|
final Uint8List? sourceImageBytes;
|
||||||
|
final DateTime savedAt;
|
||||||
|
|
||||||
|
const MosaicProjectData({
|
||||||
|
required this.useCapSize,
|
||||||
|
required this.gridWidth,
|
||||||
|
required this.gridHeight,
|
||||||
|
required this.capSize,
|
||||||
|
required this.fidelityStructure,
|
||||||
|
required this.ditheringStrength,
|
||||||
|
required this.edgeEmphasis,
|
||||||
|
required this.colorVariation,
|
||||||
|
required this.selectedPreset,
|
||||||
|
required this.sourceImageBytes,
|
||||||
|
required this.savedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'useCapSize': useCapSize,
|
||||||
|
'gridWidth': gridWidth,
|
||||||
|
'gridHeight': gridHeight,
|
||||||
|
'capSize': capSize,
|
||||||
|
'fidelityStructure': fidelityStructure,
|
||||||
|
'ditheringStrength': ditheringStrength,
|
||||||
|
'edgeEmphasis': edgeEmphasis,
|
||||||
|
'colorVariation': colorVariation,
|
||||||
|
'selectedPreset': selectedPreset,
|
||||||
|
'sourceImageBase64':
|
||||||
|
sourceImageBytes == null ? null : base64Encode(sourceImageBytes!),
|
||||||
|
'savedAt': savedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory MosaicProjectData.fromJson(Map<String, dynamic> json) {
|
||||||
|
final sourceB64 = json['sourceImageBase64'] as String?;
|
||||||
|
return MosaicProjectData(
|
||||||
|
useCapSize: json['useCapSize'] as bool? ?? false,
|
||||||
|
gridWidth: json['gridWidth'] as String? ?? '40',
|
||||||
|
gridHeight: json['gridHeight'] as String? ?? '30',
|
||||||
|
capSize: json['capSize'] as String? ?? '12',
|
||||||
|
fidelityStructure: (json['fidelityStructure'] as num?)?.toDouble() ?? 0.5,
|
||||||
|
ditheringStrength: (json['ditheringStrength'] as num?)?.toDouble() ?? 0.35,
|
||||||
|
edgeEmphasis: (json['edgeEmphasis'] as num?)?.toDouble() ?? 0.4,
|
||||||
|
colorVariation: (json['colorVariation'] as num?)?.toDouble() ?? 0.3,
|
||||||
|
selectedPreset: json['selectedPreset'] as String? ?? 'ausgewogen',
|
||||||
|
sourceImageBytes: sourceB64 == null ? null : base64Decode(sourceB64),
|
||||||
|
savedAt: DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
test/project_codec_test.dart
Normal file
32
test/project_codec_test.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:korken_mosaic/project_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('MosaicProjectData json roundtrip keeps values', () {
|
||||||
|
final original = MosaicProjectData(
|
||||||
|
useCapSize: true,
|
||||||
|
gridWidth: '50',
|
||||||
|
gridHeight: '40',
|
||||||
|
capSize: '10',
|
||||||
|
fidelityStructure: 0.3,
|
||||||
|
ditheringStrength: 0.2,
|
||||||
|
edgeEmphasis: 0.4,
|
||||||
|
colorVariation: 0.5,
|
||||||
|
selectedPreset: 'realistisch',
|
||||||
|
sourceImageBytes: Uint8List.fromList([1, 2, 3]),
|
||||||
|
savedAt: DateTime.parse('2026-01-01T12:00:00Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = MosaicProjectData.fromJson(original.toJson());
|
||||||
|
|
||||||
|
expect(decoded.useCapSize, isTrue);
|
||||||
|
expect(decoded.gridWidth, '50');
|
||||||
|
expect(decoded.gridHeight, '40');
|
||||||
|
expect(decoded.capSize, '10');
|
||||||
|
expect(decoded.selectedPreset, 'realistisch');
|
||||||
|
expect(decoded.sourceImageBytes, isNotNull);
|
||||||
|
expect(decoded.sourceImageBytes!, [1, 2, 3]);
|
||||||
|
});
|
||||||
|
}
|
||||||
24
test/widget_test.dart
Normal file
24
test/widget_test.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:korken_mosaic/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows 4-step workflow and can navigate to result step',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const KorkenMosaicApp());
|
||||||
|
|
||||||
|
expect(find.text('1) Bild'), findsOneWidget);
|
||||||
|
expect(find.text('2) Größe'), findsOneWidget);
|
||||||
|
expect(find.text('3) Farben'), findsOneWidget);
|
||||||
|
expect(find.text('4) Ergebnis'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('generate-btn')), findsOneWidget);
|
||||||
|
expect(find.text('Export JSON'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user