7 Commits

6 changed files with 884 additions and 271 deletions

136
docs/qa/issues-20260224.md Normal file
View 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 ✅ ERLEDIGT (feat/mosaic-stepper-export)
**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 ✅ ERLEDIGT (feat/mosaic-stepper-export)
**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 ✅ ERLEDIGT (feat/mosaic-stepper-export)
**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.

View 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. 23 gezielte Tests für Save/Load/Export-Pfade ergänzen.

View File

@@ -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() {
@@ -84,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});
@@ -116,6 +119,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
bool _isCaptureFlowInProgress = false; bool _isCaptureFlowInProgress = false;
bool _isRecoveringCapture = false; bool _isRecoveringCapture = false;
bool _isProjectBusy = 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;
@@ -133,6 +138,7 @@ 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); _loadProject(silent: true);
@@ -148,7 +154,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
if (state == AppLifecycleState.inactive || if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused || state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) { state == AppLifecycleState.detached) {
_saveProject(silent: true); _saveProject(silent: true, manual: false);
} }
} }
@@ -177,34 +183,104 @@ class _MosaicHomePageState extends State<MosaicHomePage>
return file.path; return file.path;
} }
Future<File> _projectFile() async { Future<Directory> _projectsDir() async {
final docs = await getApplicationDocumentsDirectory(); final docs = await getApplicationDocumentsDirectory();
return File('${docs.path}/mosaic_project.json'); final dir = Directory('${docs.path}/projects');
await dir.create(recursive: true);
return dir;
} }
Future<void> _saveProject({bool silent = false}) async { 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');
}
bool get _hasSourceImage => _sourceImageBytes != null;
bool get _canGenerate => _hasSourceImage && !_isGenerating;
int get _maxAccessibleStepIndex =>
_hasSourceImage ? MosaicFlowStep.result.index : MosaicFlowStep.image.index;
List<MosaicPaletteSnapshotEntry> _catalogSnapshotFromCurrentCatalog() {
return _catalog
.map((entry) => MosaicPaletteSnapshotEntry(
name: entry.name,
colorValue: entry.colorValue,
))
.toList(growable: false);
}
void _showMissingImageHint() {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bitte zuerst ein Bild auswählen.'),
),
);
}
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,
catalogSnapshot: _catalogSnapshotFromCurrentCatalog(),
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; if (_isProjectBusy) return;
_isProjectBusy = true; _isProjectBusy = true;
try { try {
final file = await _projectFile(); final payload = _buildProjectData();
final payload = <String, dynamic>{ final latestFile = await _latestProjectFile();
'useCapSize': _useCapSize, await latestFile.writeAsString(jsonEncode(payload.toJson()), flush: true);
'gridWidth': _gridWidthCtrl.text,
'gridHeight': _gridHeightCtrl.text, if (manual) {
'capSize': _capSizeCtrl.text, final projectsDir = await _projectsDir();
'fidelityStructure': _fidelityStructure, final snapshot = File('${projectsDir.path}/${_projectFilename()}');
'ditheringStrength': _ditheringStrength, await snapshot.writeAsString(jsonEncode(payload.toJson()), flush: true);
'edgeEmphasis': _edgeEmphasis, }
'colorVariation': _colorVariation,
'selectedPreset': _selectedPreset.name, await _refreshProjectFiles();
'activeSection': _activeSection.name,
'sourceImageBase64':
_sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!),
};
await file.writeAsString(jsonEncode(payload), flush: true);
if (!silent && mounted) { if (!silent && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Projekt gespeichert ✅')), SnackBar(
content: Text(manual
? 'Projekt gespeichert ✅'
: 'Projekt automatisch gesichert')),
); );
} }
} catch (_) { } catch (_) {
@@ -218,59 +294,60 @@ class _MosaicHomePageState extends State<MosaicHomePage>
} }
} }
Future<void> _loadProject({bool silent = false}) async { Future<void> _loadProject({bool silent = false, File? fromFile}) async {
if (_isProjectBusy) return; if (_isProjectBusy) return;
_isProjectBusy = true; _isProjectBusy = true;
try { try {
final file = await _projectFile(); 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; if (!await file.exists()) return;
final data =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final sourceB64 = data['sourceImageBase64'] as String?; final data = MosaicProjectData.fromJson(
final source = sourceB64 == null ? null : base64Decode(sourceB64); jsonDecode(await file.readAsString()) as Map<String, dynamic>,
);
final hasCatalogSnapshot = data.catalogSnapshot.isNotEmpty;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_useCapSize = data['useCapSize'] as bool? ?? _useCapSize; _useCapSize = data.useCapSize;
_gridWidthCtrl.text = _gridWidthCtrl.text = data.gridWidth;
data['gridWidth'] as String? ?? _gridWidthCtrl.text; _gridHeightCtrl.text = data.gridHeight;
_gridHeightCtrl.text = _capSizeCtrl.text = data.capSize;
data['gridHeight'] as String? ?? _gridHeightCtrl.text; _fidelityStructure = data.fidelityStructure;
_capSizeCtrl.text = data['capSize'] as String? ?? _capSizeCtrl.text; _ditheringStrength = data.ditheringStrength;
_fidelityStructure = (data['fidelityStructure'] as num?)?.toDouble() ?? _edgeEmphasis = data.edgeEmphasis;
_fidelityStructure; _colorVariation = data.colorVariation;
_ditheringStrength = (data['ditheringStrength'] as num?)?.toDouble() ??
_ditheringStrength;
_edgeEmphasis =
(data['edgeEmphasis'] as num?)?.toDouble() ?? _edgeEmphasis;
_colorVariation =
(data['colorVariation'] as num?)?.toDouble() ?? _colorVariation;
final presetName = data['selectedPreset'] as String?;
_selectedPreset = StylePreset.values.firstWhere( _selectedPreset = StylePreset.values.firstWhere(
(p) => p.name == presetName, (p) => p.name == data.selectedPreset,
orElse: () => _selectedPreset, orElse: () => StylePreset.ausgewogen,
); );
final sectionName = data['activeSection'] as String?; _sourceImageBytes = data.sourceImageBytes;
_activeSection = HomeSection.values.firstWhere(
(s) => s.name == sectionName,
orElse: () => _activeSection,
);
if (source != null) {
_sourceImageBytes = source;
_result = null; _result = null;
} _currentFlowStep =
_sourceImageBytes == null ? MosaicFlowStep.image : MosaicFlowStep.result;
_activeSection = HomeSection.mosaic;
}); });
if (source != null) { if (data.sourceImageBytes != null) {
await _generate(); await _generate(catalogSnapshotOverride: hasCatalogSnapshot ? data.catalogSnapshot : null);
} }
if (!silent && mounted) { if (!silent && mounted) {
final message = hasCatalogSnapshot
? 'Projekt geladen ✅ (mit gespeichertem Katalog-Snapshot)'
: 'Projekt geladen ✅ (ohne Snapshot: aktueller Katalog aktiv)';
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Projekt geladen ✅')), SnackBar(content: Text(message)),
); );
} }
} catch (_) { } catch (_) {
@@ -284,6 +361,79 @@ class _MosaicHomePageState extends State<MosaicHomePage>
} }
} }
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)),
@@ -326,8 +476,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
setState(() { setState(() {
_sourceImageBytes = bytes; _sourceImageBytes = bytes;
_result = null; _result = null;
_currentFlowStep = MosaicFlowStep.size;
}); });
await _saveProject(silent: true); await _saveProject(silent: true, manual: false);
} }
Future<File> _pendingCaptureFile() async { Future<File> _pendingCaptureFile() async {
@@ -759,8 +910,24 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_scheduleRegenerate(); _scheduleRegenerate();
} }
Future<void> _generate() async { Future<void> _generate({List<MosaicPaletteSnapshotEntry>? catalogSnapshotOverride}) async {
if (_sourceImageBytes == null || _catalog.isEmpty) return; if (_sourceImageBytes == null) {
_showMissingImageHint();
return;
}
final paletteSource = catalogSnapshotOverride != null && catalogSnapshotOverride.isNotEmpty
? catalogSnapshotOverride
.map((entry) => <String, dynamic>{
'name': entry.name,
'value': entry.colorValue,
})
.toList(growable: false)
: _catalog
.map((p) => <String, dynamic>{'name': p.name, 'value': p.colorValue})
.toList(growable: false);
if (paletteSource.isEmpty) return;
final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40); final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40);
final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30); final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
@@ -779,9 +946,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
'ditheringStrength': _ditheringStrength, 'ditheringStrength': _ditheringStrength,
'edgeEmphasis': _edgeEmphasis, 'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation, 'colorVariation': _colorVariation,
'palette': _catalog 'palette': paletteSource,
.map((p) => <String, dynamic>{'name': p.name, 'value': p.colorValue})
.toList(growable: false),
}; };
final out = await compute(_generateMosaicIsolate, payload); final out = await compute(_generateMosaicIsolate, payload);
@@ -813,7 +978,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
); );
_isGenerating = false; _isGenerating = false;
}); });
await _saveProject(silent: true); await _saveProject(silent: true, manual: false);
} }
@override @override
@@ -824,13 +989,15 @@ 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(
backgroundColor: Colors.white.withValues(alpha: 0.85), backgroundColor: Colors.white.withValues(alpha: 0.85),
foregroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.primary,
onPressed: _isGenerating ? null : _generate, onPressed: _canGenerate ? _generate : null,
icon: _isGenerating icon: _isGenerating
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@@ -847,7 +1014,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(
@@ -866,26 +1035,166 @@ 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: Column( child: Stepper(
currentStep: _currentFlowStep.index,
controlsBuilder: (context, details) {
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: () {
final next = _currentFlowStep.index + 1;
if (next > _maxAccessibleStepIndex) {
_showMissingImageHint();
return;
}
setState(() {
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) {
if (index > _maxAccessibleStepIndex) {
_showMissingImageHint();
return;
}
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
runSpacing: 10, spacing: 8,
spacing: 10, runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
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'),
@@ -900,41 +1209,18 @@ class _MosaicHomePageState extends State<MosaicHomePage>
icon: const Icon(Icons.folder_open_outlined), icon: const Icon(Icons.folder_open_outlined),
label: const Text('Laden'), label: const Text('Laden'),
), ),
if (_sourceImageBytes != null)
const Chip(label: Text('Image loaded ✅')),
], ],
), ),
if (_sourceImageBytes != null) ...[ if (_sourceImageBytes != null) ...[
const SizedBox(height: 10), const SizedBox(height: 8),
ClipRRect( const Chip(label: Text('Image loaded ✅')),
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 220,
width: double.infinity,
child: InteractiveViewer(
minScale: 1,
maxScale: 8,
panEnabled: true,
child: Image.memory(
_sourceImageBytes!,
fit: BoxFit.contain,
width: double.infinity,
),
),
),
),
const SizedBox(height: 6),
Text(
'Pinch zum Zoomen, mit einem Finger verschieben',
style: Theme.of(context).textTheme.bodySmall,
),
], ],
], ],
), );
), }
const SizedBox(height: 12),
_GlassCard( Widget _buildSizeStep() {
child: Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SegmentedButton<bool>( SegmentedButton<bool>(
@@ -948,7 +1234,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_scheduleRegenerate(); _scheduleRegenerate();
}, },
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
if (!_useCapSize) if (!_useCapSize)
Row( Row(
children: [ children: [
@@ -956,16 +1242,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: const InputDecoration( decoration: const InputDecoration(labelText: 'Grid Height')),
labelText: 'Grid Height')),
), ),
], ],
) )
@@ -977,32 +1261,25 @@ 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', Text('Style Preset', style: Theme.of(context).textTheme.titleMedium),
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
SegmentedButton<StylePreset>( SegmentedButton<StylePreset>(
segments: const [ segments: const [
ButtonSegment( ButtonSegment(value: StylePreset.realistisch, label: Text('Realistisch')),
value: StylePreset.realistisch, ButtonSegment(value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
label: Text('Realistisch')), ButtonSegment(value: StylePreset.kuenstlerisch, label: Text('Künstlerisch')),
ButtonSegment(
value: StylePreset.ausgewogen,
label: Text('Ausgewogen')),
ButtonSegment(
value: StylePreset.kuenstlerisch,
label: Text('Künstlerisch')),
], ],
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',
@@ -1012,69 +1289,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})', Wrap(
style: Theme.of(context).textTheme.titleMedium), spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
key: const Key('generate-btn'),
onPressed: _canGenerate ? _generate : null,
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),
@@ -1082,12 +1328,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
trailing: Text('${e.value} caps'), trailing: Text('${e.value} caps'),
), ),
), ),
]
], ],
),
),
],
],
),
); );
} }

95
lib/project_codec.dart Normal file
View File

@@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:typed_data';
class MosaicPaletteSnapshotEntry {
final String name;
final int colorValue;
const MosaicPaletteSnapshotEntry({
required this.name,
required this.colorValue,
});
Map<String, dynamic> toJson() => {
'name': name,
'colorValue': colorValue,
};
factory MosaicPaletteSnapshotEntry.fromJson(Map<String, dynamic> json) {
return MosaicPaletteSnapshotEntry(
name: json['name'] as String? ?? 'Unbenannt',
colorValue: (json['colorValue'] as num?)?.toInt() ?? 0xFF000000,
);
}
}
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 List<MosaicPaletteSnapshotEntry> catalogSnapshot;
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.catalogSnapshot,
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!),
'catalogSnapshot': catalogSnapshot.map((entry) => entry.toJson()).toList(),
'savedAt': savedAt.toIso8601String(),
};
factory MosaicProjectData.fromJson(Map<String, dynamic> json) {
final sourceB64 = json['sourceImageBase64'] as String?;
final snapshotRaw = (json['catalogSnapshot'] as List?) ?? const [];
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),
catalogSnapshot: snapshotRaw
.whereType<Map>()
.map((entry) => MosaicPaletteSnapshotEntry.fromJson(
Map<String, dynamic>.from(entry)))
.toList(growable: false),
savedAt:
DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(),
);
}
}

View File

@@ -0,0 +1,53 @@
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 including catalog snapshot', () {
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]),
catalogSnapshot: const [
MosaicPaletteSnapshotEntry(name: 'White', colorValue: 0xFFF2F2F2),
MosaicPaletteSnapshotEntry(name: 'Blue', colorValue: 0xFF3F6FD8),
],
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]);
expect(decoded.catalogSnapshot.length, 2);
expect(decoded.catalogSnapshot.first.name, 'White');
expect(decoded.catalogSnapshot.last.colorValue, 0xFF3F6FD8);
});
test('MosaicProjectData defaults to empty snapshot when old project has none', () {
final decoded = MosaicProjectData.fromJson({
'useCapSize': false,
'gridWidth': '40',
'gridHeight': '30',
'capSize': '12',
'selectedPreset': 'ausgewogen',
'savedAt': '2026-01-01T12:00:00Z',
});
expect(decoded.catalogSnapshot, isEmpty);
expect(decoded.sourceImageBytes, isNull);
});
}

35
test/widget_test.dart Normal file
View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:korken_mosaic/main.dart';
void main() {
testWidgets('stepper blocks forward navigation without image and shows hint',
(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.pump();
// Stays on step 1 because no source image is available yet.
expect(find.text('Import target image'), findsOneWidget);
expect(find.text('Bitte zuerst ein Bild auswählen.'), findsOneWidget);
});
testWidgets('generate actions are disabled without image',
(WidgetTester tester) async {
await tester.pumpWidget(const KorkenMosaicApp());
final fab = tester.widget<FloatingActionButton>(
find.byType(FloatingActionButton),
);
expect(fab.onPressed, isNull);
// Result-step action is not reachable before image selection.
expect(find.byKey(const Key('generate-btn')), findsNothing);
});
}