From 814705cac66100b9302dbfac20257a9a61a595e8 Mon Sep 17 00:00:00 2001 From: gary Date: Mon, 23 Feb 2026 08:25:06 +0100 Subject: [PATCH 1/7] feat(projects): add Projects tab with load/delete list --- lib/main.dart | 130 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e10f811..8939602 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -84,7 +84,7 @@ enum CatalogViewMode { list, grid } enum ColorExtractionMode { dominant, average } -enum HomeSection { mosaic, catalog } +enum HomeSection { mosaic, catalog, projects } class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -116,6 +116,7 @@ class _MosaicHomePageState extends State bool _isCaptureFlowInProgress = false; bool _isRecoveringCapture = false; bool _isProjectBusy = false; + List _projectFiles = []; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -133,6 +134,7 @@ class _MosaicHomePageState extends State _gridHeightCtrl.addListener(_scheduleRegenerate); _capSizeCtrl.addListener(_scheduleRegenerate); _loadCatalog(); + _refreshProjectFiles(); WidgetsBinding.instance.addPostFrameCallback((_) { _recoverCaptureOnResumeOrStart(); _loadProject(silent: true); @@ -177,16 +179,42 @@ class _MosaicHomePageState extends State return file.path; } - Future _projectFile() async { + Future _projectsDir() async { final docs = await getApplicationDocumentsDirectory(); - return File('${docs.path}/mosaic_project.json'); + 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 _refreshProjectFiles() async { + try { + final dir = await _projectsDir(); + final files = dir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.json')) + .toList() + ..sort((a, b) => b.path.compareTo(a.path)); + if (mounted) setState(() => _projectFiles = files); + } catch (_) {} } Future _saveProject({bool silent = false}) async { if (_isProjectBusy) return; _isProjectBusy = true; try { - final file = await _projectFile(); + final projectsDir = await _projectsDir(); + final file = File('${projectsDir.path}/${_projectFilename()}'); final payload = { 'useCapSize': _useCapSize, 'gridWidth': _gridWidthCtrl.text, @@ -197,11 +225,11 @@ class _MosaicHomePageState extends State 'edgeEmphasis': _edgeEmphasis, 'colorVariation': _colorVariation, 'selectedPreset': _selectedPreset.name, - 'activeSection': _activeSection.name, 'sourceImageBase64': _sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!), }; await file.writeAsString(jsonEncode(payload), flush: true); + await _refreshProjectFiles(); if (!silent && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Projekt gespeichert ✅')), @@ -218,15 +246,19 @@ class _MosaicHomePageState extends State } } - Future _loadProject({bool silent = false}) async { + Future _loadProject({bool silent = false, File? fromFile}) async { if (_isProjectBusy) return; _isProjectBusy = true; try { - final file = await _projectFile(); + File? file = fromFile; + if (file == null) { + await _refreshProjectFiles(); + if (_projectFiles.isEmpty) return; + file = _projectFiles.first; + } if (!await file.exists()) return; - final data = - jsonDecode(await file.readAsString()) as Map; + final data = jsonDecode(await file.readAsString()) as Map; final sourceB64 = data['sourceImageBase64'] as String?; final source = sourceB64 == null ? null : base64Decode(sourceB64); @@ -253,21 +285,14 @@ class _MosaicHomePageState extends State orElse: () => _selectedPreset, ); - final sectionName = data['activeSection'] as String?; - _activeSection = HomeSection.values.firstWhere( - (s) => s.name == sectionName, - orElse: () => _activeSection, - ); - if (source != null) { _sourceImageBytes = source; _result = null; } + _activeSection = HomeSection.mosaic; }); - if (source != null) { - await _generate(); - } + if (source != null) await _generate(); if (!silent && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Projekt geladen ✅')), @@ -284,6 +309,24 @@ class _MosaicHomePageState extends State } } + Future _deleteProject(File file) async { + 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 _loadCatalog() async { final defaults = [ CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)), @@ -824,7 +867,9 @@ class _MosaicHomePageState extends State backgroundColor: Colors.white.withValues(alpha: 0.45), title: Text(_activeSection == HomeSection.mosaic ? 'Bottle-Cap Mosaic Studio' - : 'Cap Catalog'), + : _activeSection == HomeSection.catalog + ? 'Cap Catalog' + : 'Projekte'), ), floatingActionButton: _activeSection == HomeSection.mosaic ? FloatingActionButton.extended( @@ -847,7 +892,9 @@ class _MosaicHomePageState extends State ? const Center(child: CircularProgressIndicator()) : _activeSection == HomeSection.mosaic ? _buildMosaicScreen() - : _buildCatalogScreen(), + : _activeSection == HomeSection.catalog + ? _buildCatalogScreen() + : _buildProjectsScreen(), ], ), bottomNavigationBar: NavigationBar( @@ -866,11 +913,54 @@ class _MosaicHomePageState extends State selectedIcon: Icon(Icons.inventory_2), label: 'Catalog', ), + NavigationDestination( + icon: Icon(Icons.folder_copy_outlined), + selectedIcon: Icon(Icons.folder_copy), + label: 'Projekte', + ), ], ), ); } + Widget _buildProjectsScreen() { + if (_projectFiles.isEmpty) { + return const Center(child: Text('Noch keine gespeicherten Projekte.')); + } + + return ListView.builder( + padding: const EdgeInsets.all(14), + itemCount: _projectFiles.length, + itemBuilder: (context, index) { + final file = _projectFiles[index]; + 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() { return Padding( padding: const EdgeInsets.all(14), -- 2.49.1 From ab7aa625e6cceaf85b289352e258bdaed0de83ad Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:30:33 +0100 Subject: [PATCH 2/7] Implement 4-step mosaic flow, robust project autosave, and JSON export --- lib/main.dart | 665 ++++++++++++++++++++--------------- lib/project_codec.dart | 62 ++++ test/project_codec_test.dart | 32 ++ test/widget_test.dart | 24 ++ 4 files changed, 500 insertions(+), 283 deletions(-) create mode 100644 lib/project_codec.dart create mode 100644 test/project_codec_test.dart create mode 100644 test/widget_test.dart diff --git a/lib/main.dart b/lib/main.dart index 8939602..59adae3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; +import 'package:korken_mosaic/project_codec.dart'; import 'package:path_provider/path_provider.dart'; void main() { @@ -86,6 +87,8 @@ enum ColorExtractionMode { dominant, average } enum HomeSection { mosaic, catalog, projects } +enum MosaicFlowStep { image, size, colors, result } + class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -117,6 +120,7 @@ class _MosaicHomePageState extends State bool _isRecoveringCapture = false; bool _isProjectBusy = false; List _projectFiles = []; + MosaicFlowStep _currentFlowStep = MosaicFlowStep.image; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -150,7 +154,7 @@ class _MosaicHomePageState extends State if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { - _saveProject(silent: true); + _saveProject(silent: true, manual: false); } } @@ -196,43 +200,61 @@ class _MosaicHomePageState extends State return 'project_$stamp.json'; } + Future _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 _refreshProjectFiles() async { try { final dir = await _projectsDir(); final files = dir .listSync() .whereType() - .where((f) => f.path.endsWith('.json')) + .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 _saveProject({bool silent = false}) async { + Future _saveProject({bool silent = false, bool manual = true}) async { if (_isProjectBusy) return; _isProjectBusy = true; try { - final projectsDir = await _projectsDir(); - final file = File('${projectsDir.path}/${_projectFilename()}'); - final payload = { - 'useCapSize': _useCapSize, - 'gridWidth': _gridWidthCtrl.text, - 'gridHeight': _gridHeightCtrl.text, - 'capSize': _capSizeCtrl.text, - 'fidelityStructure': _fidelityStructure, - 'ditheringStrength': _ditheringStrength, - 'edgeEmphasis': _edgeEmphasis, - 'colorVariation': _colorVariation, - 'selectedPreset': _selectedPreset.name, - 'sourceImageBase64': - _sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!), - }; - await file.writeAsString(jsonEncode(payload), flush: true); + 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( - const SnackBar(content: Text('Projekt gespeichert ✅')), + SnackBar( + content: Text(manual + ? 'Projekt gespeichert ✅' + : 'Projekt automatisch gesichert')), ); } } catch (_) { @@ -252,47 +274,46 @@ class _MosaicHomePageState extends State try { File? file = fromFile; if (file == null) { - await _refreshProjectFiles(); - if (_projectFiles.isEmpty) return; - file = _projectFiles.first; + 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 = jsonDecode(await file.readAsString()) as Map; - final sourceB64 = data['sourceImageBase64'] as String?; - final source = sourceB64 == null ? null : base64Decode(sourceB64); + final data = MosaicProjectData.fromJson( + jsonDecode(await file.readAsString()) as Map, + ); if (!mounted) return; setState(() { - _useCapSize = data['useCapSize'] as bool? ?? _useCapSize; - _gridWidthCtrl.text = - data['gridWidth'] as String? ?? _gridWidthCtrl.text; - _gridHeightCtrl.text = - data['gridHeight'] as String? ?? _gridHeightCtrl.text; - _capSizeCtrl.text = data['capSize'] as String? ?? _capSizeCtrl.text; - _fidelityStructure = (data['fidelityStructure'] as num?)?.toDouble() ?? - _fidelityStructure; - _ditheringStrength = (data['ditheringStrength'] as num?)?.toDouble() ?? - _ditheringStrength; - _edgeEmphasis = - (data['edgeEmphasis'] as num?)?.toDouble() ?? _edgeEmphasis; - _colorVariation = - (data['colorVariation'] as num?)?.toDouble() ?? _colorVariation; + _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; - final presetName = data['selectedPreset'] as String?; _selectedPreset = StylePreset.values.firstWhere( - (p) => p.name == presetName, - orElse: () => _selectedPreset, + (p) => p.name == data.selectedPreset, + orElse: () => StylePreset.ausgewogen, ); - if (source != null) { - _sourceImageBytes = source; + if (data.sourceImageBytes != null) { + _sourceImageBytes = data.sourceImageBytes; _result = null; + _currentFlowStep = MosaicFlowStep.result; } _activeSection = HomeSection.mosaic; }); - if (source != null) await _generate(); + if (data.sourceImageBytes != null) await _generate(); if (!silent && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Projekt geladen ✅')), @@ -310,6 +331,24 @@ class _MosaicHomePageState extends State } Future _deleteProject(File file) async { + final ok = await showDialog( + 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(); @@ -327,6 +366,43 @@ class _MosaicHomePageState extends State } } + Future _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 _loadCatalog() async { final defaults = [ CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)), @@ -369,8 +445,9 @@ class _MosaicHomePageState extends State setState(() { _sourceImageBytes = bytes; _result = null; + _currentFlowStep = MosaicFlowStep.size; }); - await _saveProject(silent: true); + await _saveProject(silent: true, manual: false); } Future _pendingCaptureFile() async { @@ -856,7 +933,7 @@ class _MosaicHomePageState extends State ); _isGenerating = false; }); - await _saveProject(silent: true); + await _saveProject(silent: true, manual: false); } @override @@ -924,40 +1001,61 @@ class _MosaicHomePageState extends State } Widget _buildProjectsScreen() { - if (_projectFiles.isEmpty) { - return const Center(child: Text('Noch keine gespeicherten Projekte.')); - } - - return ListView.builder( + return ListView( padding: const EdgeInsets.all(14), - itemCount: _projectFiles.length, - itemBuilder: (context, index) { - final file = _projectFiles[index]; - 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), - ), - ], - ), + 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), + ), + ], + ), + ), + ); + }), + ], ); } @@ -967,220 +1065,221 @@ class _MosaicHomePageState extends State child: ListView( children: [ _GlassCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - runSpacing: 10, - spacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, + child: Stepper( + currentStep: _currentFlowStep.index, + controlsBuilder: (context, details) { + final isLast = _currentFlowStep == MosaicFlowStep.result; + return Row( children: [ - FilledButton.icon( - onPressed: _pickImage, - icon: const Icon(Icons.image_outlined), - label: const Text('Import target image'), + FilledButton( + onPressed: details.onStepContinue, + child: Text(isLast ? 'Fertig' : 'Weiter'), ), - OutlinedButton.icon( - 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 Chip(label: Text('Image loaded ✅')), - ], - ), - if (_sourceImageBytes != null) ...[ - const SizedBox(height: 10), - ClipRRect( - 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(width: 8), + if (_currentFlowStep.index > 0) + OutlinedButton( + onPressed: details.onStepCancel, + child: const Text('Zurück'), ), - ), - ), - const SizedBox(height: 6), - Text( - 'Pinch zum Zoomen, mit einem Finger verschieben', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + ], + ); + }, + 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(), + ), ], ), ), - const SizedBox(height: 12), - _GlassCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment(value: false, label: Text('Grid W x H')), - ButtonSegment(value: true, label: Text('Cap size (px)')), - ], - selected: {_useCapSize}, - onSelectionChanged: (s) { - setState(() => _useCapSize = s.first); - _scheduleRegenerate(); - }, - ), - const SizedBox(height: 10), - if (!_useCapSize) - Row( - children: [ - Expanded( - child: TextField( - controller: _gridWidthCtrl, - keyboardType: TextInputType.number, - decoration: - const InputDecoration(labelText: 'Grid Width')), - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - controller: _gridHeightCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Grid Height')), - ), - ], - ) - else - TextField( - controller: _capSizeCtrl, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Approx cap size in source image (pixels)'), - ), - ], - ), - ), - const SizedBox(height: 12), - _GlassCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Style Preset', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: StylePreset.realistisch, - label: Text('Realistisch')), - ButtonSegment( - value: StylePreset.ausgewogen, - label: Text('Ausgewogen')), - ButtonSegment( - value: StylePreset.kuenstlerisch, - label: Text('Künstlerisch')), - ], - selected: {_selectedPreset}, - onSelectionChanged: (s) => _applyPreset(s.first), - ), - const SizedBox(height: 8), - _SliderRow( - label: 'Fidelity ↔ Structure', - leftLabel: 'Fidelity', - rightLabel: 'Structure', - value: _fidelityStructure, - onChanged: (v) { - setState(() => _fidelityStructure = v); - _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) ...[ - _GlassCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Preview (${_result!.width} x ${_result!.height})', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(18), - 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), - ..._result!.sortedCounts.map( - (e) => ListTile( - dense: true, - leading: CircleAvatar(backgroundColor: e.key.color), - title: Text(e.key.name), - trailing: Text('${e.value} caps'), - ), - ), - ], - ), - ), - ], ], ), ); } + Widget _buildImageStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + key: const Key('pick-image-btn'), + onPressed: _pickImage, + icon: const Icon(Icons.image_outlined), + label: const Text('Import target image'), + ), + OutlinedButton.icon( + 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: 8), + const Chip(label: Text('Image loaded ✅')), + ], + ], + ); + } + + Widget _buildSizeStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: false, label: Text('Grid W x H')), + ButtonSegment(value: true, label: Text('Cap size (px)')), + ], + selected: {_useCapSize}, + onSelectionChanged: (s) { + setState(() => _useCapSize = s.first); + _scheduleRegenerate(); + }, + ), + const SizedBox(height: 8), + if (!_useCapSize) + Row( + children: [ + Expanded( + child: TextField( + controller: _gridWidthCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Grid Width')), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _gridHeightCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Grid Height')), + ), + ], + ) + else + TextField( + controller: _capSizeCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Approx cap size in source image (pixels)'), + ), + ], + ); + } + + Widget _buildColorStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Style Preset', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: StylePreset.realistisch, label: Text('Realistisch')), + ButtonSegment(value: StylePreset.ausgewogen, label: Text('Ausgewogen')), + ButtonSegment(value: StylePreset.kuenstlerisch, label: Text('Künstlerisch')), + ], + selected: {_selectedPreset}, + onSelectionChanged: (s) => _applyPreset(s.first), + ), + Text('Aktive Katalogfarben: ${_catalog.length}'), + _SliderRow( + label: 'Fidelity ↔ Structure', + leftLabel: 'Fidelity', + rightLabel: 'Structure', + value: _fidelityStructure, + onChanged: (v) { + setState(() => _fidelityStructure = v); + _onStyleChanged(); + }), + ], + ); + } + + Widget _buildResultStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + if (_result != null) ...[ + Text('Preview (${_result!.width} x ${_result!.height})'), + const SizedBox(height: 6), + Image.memory(_result!.previewPng, gaplessPlayback: true), + const SizedBox(height: 8), + ..._result!.sortedCounts.take(8).map( + (e) => ListTile( + dense: true, + leading: CircleAvatar(backgroundColor: e.key.color), + title: Text(e.key.name), + trailing: Text('${e.value} caps'), + ), + ), + ] + ], + ); + } + Widget _buildCatalogScreen() { return Padding( padding: const EdgeInsets.all(14), diff --git a/lib/project_codec.dart b/lib/project_codec.dart new file mode 100644 index 0000000..bccdb78 --- /dev/null +++ b/lib/project_codec.dart @@ -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 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 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(), + ); + } +} diff --git a/test/project_codec_test.dart b/test/project_codec_test.dart new file mode 100644 index 0000000..e9ad709 --- /dev/null +++ b/test/project_codec_test.dart @@ -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]); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..1b9a906 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +} -- 2.49.1 From df7406b4945a8d8800a095252edc834eff6af83d Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:34:30 +0100 Subject: [PATCH 3/7] Add independent review for changes since 814705c --- docs/review/review-20260224.md | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/review/review-20260224.md diff --git a/docs/review/review-20260224.md b/docs/review/review-20260224.md new file mode 100644 index 0000000..c027244 --- /dev/null +++ b/docs/review/review-20260224.md @@ -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. -- 2.49.1 From 25d570c779e973e75020faa84052d4dfd28d4eb7 Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:34:56 +0100 Subject: [PATCH 4/7] docs(qa): add static QA issue review for stepper/save-load/export flows --- docs/qa/issues-20260224.md | 136 +++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/qa/issues-20260224.md diff --git a/docs/qa/issues-20260224.md b/docs/qa/issues-20260224.md new file mode 100644 index 0000000..a7d6fe6 --- /dev/null +++ b/docs/qa/issues-20260224.md @@ -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. -- 2.49.1 From fd72d53d2a7a240c4330e05c8b40fe45fc7a1a0d Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:43:25 +0100 Subject: [PATCH 5/7] feat(project): persist catalog snapshot in project data --- lib/project_codec.dart | 37 ++++++++++++++++++++++++++++++++++-- test/project_codec_test.dart | 23 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/project_codec.dart b/lib/project_codec.dart index bccdb78..599d20e 100644 --- a/lib/project_codec.dart +++ b/lib/project_codec.dart @@ -1,6 +1,28 @@ import 'dart:convert'; import 'dart:typed_data'; +class MosaicPaletteSnapshotEntry { + final String name; + final int colorValue; + + const MosaicPaletteSnapshotEntry({ + required this.name, + required this.colorValue, + }); + + Map toJson() => { + 'name': name, + 'colorValue': colorValue, + }; + + factory MosaicPaletteSnapshotEntry.fromJson(Map json) { + return MosaicPaletteSnapshotEntry( + name: json['name'] as String? ?? 'Unbenannt', + colorValue: (json['colorValue'] as num?)?.toInt() ?? 0xFF000000, + ); + } +} + class MosaicProjectData { final bool useCapSize; final String gridWidth; @@ -12,6 +34,7 @@ class MosaicProjectData { final double colorVariation; final String selectedPreset; final Uint8List? sourceImageBytes; + final List catalogSnapshot; final DateTime savedAt; const MosaicProjectData({ @@ -25,6 +48,7 @@ class MosaicProjectData { required this.colorVariation, required this.selectedPreset, required this.sourceImageBytes, + required this.catalogSnapshot, required this.savedAt, }); @@ -40,23 +64,32 @@ class MosaicProjectData { 'selectedPreset': selectedPreset, 'sourceImageBase64': sourceImageBytes == null ? null : base64Encode(sourceImageBytes!), + 'catalogSnapshot': catalogSnapshot.map((entry) => entry.toJson()).toList(), 'savedAt': savedAt.toIso8601String(), }; factory MosaicProjectData.fromJson(Map 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, + 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(), + catalogSnapshot: snapshotRaw + .whereType() + .map((entry) => MosaicPaletteSnapshotEntry.fromJson( + Map.from(entry))) + .toList(growable: false), + savedAt: + DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(), ); } } diff --git a/test/project_codec_test.dart b/test/project_codec_test.dart index e9ad709..af59a9f 100644 --- a/test/project_codec_test.dart +++ b/test/project_codec_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:korken_mosaic/project_codec.dart'; void main() { - test('MosaicProjectData json roundtrip keeps values', () { + test('MosaicProjectData json roundtrip keeps values including catalog snapshot', () { final original = MosaicProjectData( useCapSize: true, gridWidth: '50', @@ -16,6 +16,10 @@ void main() { 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'), ); @@ -28,5 +32,22 @@ void main() { 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); }); } -- 2.49.1 From 1a21bc18bbf37d37699b0e6782b4169c514a7126 Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:43:28 +0100 Subject: [PATCH 6/7] fix(flow): guard stepper and reset state on image-less project load --- lib/main.dart | 83 +++++++++++++++++++++++++++++++++++-------- test/widget_test.dart | 27 +++++++++----- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 59adae3..905ccd8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -205,6 +205,31 @@ class _MosaicHomePageState extends State 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 _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, @@ -217,6 +242,7 @@ class _MosaicHomePageState extends State colorVariation: _colorVariation, selectedPreset: _selectedPreset.name, sourceImageBytes: _sourceImageBytes, + catalogSnapshot: _catalogSnapshotFromCurrentCatalog(), savedAt: DateTime.now(), ); } @@ -288,6 +314,7 @@ class _MosaicHomePageState extends State final data = MosaicProjectData.fromJson( jsonDecode(await file.readAsString()) as Map, ); + final hasCatalogSnapshot = data.catalogSnapshot.isNotEmpty; if (!mounted) return; setState(() { @@ -305,18 +332,22 @@ class _MosaicHomePageState extends State orElse: () => StylePreset.ausgewogen, ); - if (data.sourceImageBytes != null) { - _sourceImageBytes = data.sourceImageBytes; - _result = null; - _currentFlowStep = MosaicFlowStep.result; - } + _sourceImageBytes = data.sourceImageBytes; + _result = null; + _currentFlowStep = + _sourceImageBytes == null ? MosaicFlowStep.image : MosaicFlowStep.result; _activeSection = HomeSection.mosaic; }); - if (data.sourceImageBytes != null) await _generate(); + if (data.sourceImageBytes != null) { + await _generate(catalogSnapshotOverride: hasCatalogSnapshot ? data.catalogSnapshot : null); + } if (!silent && mounted) { + final message = hasCatalogSnapshot + ? 'Projekt geladen ✅ (mit gespeichertem Katalog-Snapshot)' + : 'Projekt geladen ✅ (ohne Snapshot: aktueller Katalog aktiv)'; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Projekt geladen ✅')), + SnackBar(content: Text(message)), ); } } catch (_) { @@ -879,8 +910,24 @@ class _MosaicHomePageState extends State _scheduleRegenerate(); } - Future _generate() async { - if (_sourceImageBytes == null || _catalog.isEmpty) return; + Future _generate({List? catalogSnapshotOverride}) async { + if (_sourceImageBytes == null) { + _showMissingImageHint(); + return; + } + + final paletteSource = catalogSnapshotOverride != null && catalogSnapshotOverride.isNotEmpty + ? catalogSnapshotOverride + .map((entry) => { + 'name': entry.name, + 'value': entry.colorValue, + }) + .toList(growable: false) + : _catalog + .map((p) => {'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 gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30); @@ -899,9 +946,7 @@ class _MosaicHomePageState extends State 'ditheringStrength': _ditheringStrength, 'edgeEmphasis': _edgeEmphasis, 'colorVariation': _colorVariation, - 'palette': _catalog - .map((p) => {'name': p.name, 'value': p.colorValue}) - .toList(growable: false), + 'palette': paletteSource, }; final out = await compute(_generateMosaicIsolate, payload); @@ -952,7 +997,7 @@ class _MosaicHomePageState extends State ? FloatingActionButton.extended( backgroundColor: Colors.white.withValues(alpha: 0.85), foregroundColor: Theme.of(context).colorScheme.primary, - onPressed: _isGenerating ? null : _generate, + onPressed: _canGenerate ? _generate : null, icon: _isGenerating ? const SizedBox( width: 18, @@ -1085,8 +1130,12 @@ class _MosaicHomePageState extends State ); }, onStepContinue: () { + final next = _currentFlowStep.index + 1; + if (next > _maxAccessibleStepIndex) { + _showMissingImageHint(); + return; + } setState(() { - final next = _currentFlowStep.index + 1; if (next <= MosaicFlowStep.result.index) { _currentFlowStep = MosaicFlowStep.values[next]; } @@ -1101,6 +1150,10 @@ class _MosaicHomePageState extends State }); }, onStepTapped: (index) { + if (index > _maxAccessibleStepIndex) { + _showMissingImageHint(); + return; + } setState(() => _currentFlowStep = MosaicFlowStep.values[index]); }, steps: [ @@ -1250,7 +1303,7 @@ class _MosaicHomePageState extends State children: [ FilledButton.icon( key: const Key('generate-btn'), - onPressed: _isGenerating ? null : _generate, + onPressed: _canGenerate ? _generate : null, icon: const Icon(Icons.auto_fix_high_rounded), label: const Text('Generate Mosaic'), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 1b9a906..082f5b4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; 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', + testWidgets('stepper blocks forward navigation without image and shows hint', (WidgetTester tester) async { await tester.pumpWidget(const KorkenMosaicApp()); @@ -12,13 +13,23 @@ void main() { 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(); + await tester.pump(); - expect(find.byKey(const Key('generate-btn')), findsOneWidget); - expect(find.text('Export JSON'), findsOneWidget); + // 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( + find.byType(FloatingActionButton), + ); + expect(fab.onPressed, isNull); + + // Result-step action is not reachable before image selection. + expect(find.byKey(const Key('generate-btn')), findsNothing); }); } -- 2.49.1 From 7bd109b7289dfa5726d390465773076b4ced8b38 Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:43:30 +0100 Subject: [PATCH 7/7] docs(qa): mark P1 issues 1-3 as resolved --- docs/qa/issues-20260224.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/qa/issues-20260224.md b/docs/qa/issues-20260224.md index a7d6fe6..6193275 100644 --- a/docs/qa/issues-20260224.md +++ b/docs/qa/issues-20260224.md @@ -2,7 +2,7 @@ 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 +## Issue 1 (P1) – 4-Step-Flow lässt Fortschritt ohne Pflichtdaten zu ✅ ERLEDIGT (feat/mosaic-stepper-export) **Bereich:** 4-Step-Flow **Beobachtung** @@ -27,7 +27,7 @@ Scope: Agentische, statische QA-Review (Flutter/Dart CLI lokal nicht verfügbar: --- -## Issue 2 (P1) – Projekt-Load mit leerem Bild leert bestehenden Zustand nicht +## Issue 2 (P1) – Projekt-Load mit leerem Bild leert bestehenden Zustand nicht ✅ ERLEDIGT (feat/mosaic-stepper-export) **Bereich:** Save/Load-Flow **Beobachtung** @@ -49,7 +49,7 @@ Scope: Agentische, statische QA-Review (Flutter/Dart CLI lokal nicht verfügbar: --- -## Issue 3 (P1) – Projekt-Snapshots sind nicht reproduzierbar, da Katalog nicht versioniert wird +## 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** -- 2.49.1