fix(flow): guard stepper and reset state on image-less project load

This commit is contained in:
gary
2026-02-24 21:43:28 +01:00
parent fd72d53d2a
commit 1a21bc18bb
2 changed files with 87 additions and 23 deletions

View File

@@ -205,6 +205,31 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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,
@@ -217,6 +242,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
colorVariation: _colorVariation,
selectedPreset: _selectedPreset.name,
sourceImageBytes: _sourceImageBytes,
catalogSnapshot: _catalogSnapshotFromCurrentCatalog(),
savedAt: DateTime.now(),
);
}
@@ -288,6 +314,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
final data = MosaicProjectData.fromJson(
jsonDecode(await file.readAsString()) as Map<String, dynamic>,
);
final hasCatalogSnapshot = data.catalogSnapshot.isNotEmpty;
if (!mounted) return;
setState(() {
@@ -305,18 +332,22 @@ class _MosaicHomePageState extends State<MosaicHomePage>
orElse: () => StylePreset.ausgewogen,
);
if (data.sourceImageBytes != null) {
_sourceImageBytes = data.sourceImageBytes;
_result = null;
_currentFlowStep = MosaicFlowStep.result;
}
_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<MosaicHomePage>
_scheduleRegenerate();
}
Future<void> _generate() async {
if (_sourceImageBytes == null || _catalog.isEmpty) return;
Future<void> _generate({List<MosaicPaletteSnapshotEntry>? catalogSnapshotOverride}) async {
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 gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
@@ -899,9 +946,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
'ditheringStrength': _ditheringStrength,
'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation,
'palette': _catalog
.map((p) => <String, dynamic>{'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<MosaicHomePage>
? 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<MosaicHomePage>
);
},
onStepContinue: () {
setState(() {
final next = _currentFlowStep.index + 1;
if (next > _maxAccessibleStepIndex) {
_showMissingImageHint();
return;
}
setState(() {
if (next <= MosaicFlowStep.result.index) {
_currentFlowStep = MosaicFlowStep.values[next];
}
@@ -1101,6 +1150,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
});
},
onStepTapped: (index) {
if (index > _maxAccessibleStepIndex) {
_showMissingImageHint();
return;
}
setState(() => _currentFlowStep = MosaicFlowStep.values[index]);
},
steps: [
@@ -1250,7 +1303,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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'),
),

View File

@@ -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<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);
});
}