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