From 1a21bc18bbf37d37699b0e6782b4169c514a7126 Mon Sep 17 00:00:00 2001 From: gary Date: Tue, 24 Feb 2026 21:43:28 +0100 Subject: [PATCH] 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); }); }