diff --git a/lib/main.dart b/lib/main.dart index 78ad4c0..01ec33a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,7 +62,8 @@ class KorkenMosaicApp extends StatelessWidget { indicatorColor: const Color(0x804FD6E8), backgroundColor: Colors.white.withValues(alpha: 0.76), labelTextStyle: WidgetStatePropertyAll( - TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600), + TextStyle( + color: colorScheme.onSurface, fontWeight: FontWeight.w600), ), ), sliderTheme: SliderThemeData( @@ -114,6 +115,7 @@ class _MosaicHomePageState extends State ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant; bool _isCaptureFlowInProgress = false; bool _isRecoveringCapture = false; + bool _isProjectBusy = false; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -133,6 +135,7 @@ class _MosaicHomePageState extends State _loadCatalog(); WidgetsBinding.instance.addPostFrameCallback((_) { _recoverCaptureOnResumeOrStart(); + _loadProject(silent: true); }); } @@ -140,6 +143,12 @@ class _MosaicHomePageState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _recoverCaptureOnResumeOrStart(); + _loadProject(silent: true); + } + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _saveProject(silent: true); } } @@ -168,6 +177,113 @@ class _MosaicHomePageState extends State return file.path; } + Future _projectFile() async { + final docs = await getApplicationDocumentsDirectory(); + return File('${docs.path}/mosaic_project.json'); + } + + Future _saveProject({bool silent = false}) async { + if (_isProjectBusy) return; + _isProjectBusy = true; + try { + final file = await _projectFile(); + final payload = { + 'useCapSize': _useCapSize, + 'gridWidth': _gridWidthCtrl.text, + 'gridHeight': _gridHeightCtrl.text, + 'capSize': _capSizeCtrl.text, + 'fidelityStructure': _fidelityStructure, + 'ditheringStrength': _ditheringStrength, + 'edgeEmphasis': _edgeEmphasis, + 'colorVariation': _colorVariation, + 'selectedPreset': _selectedPreset.name, + 'activeSection': _activeSection.name, + 'sourceImageBase64': + _sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!), + }; + await file.writeAsString(jsonEncode(payload), flush: true); + if (!silent && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Projekt gespeichert ✅')), + ); + } + } catch (_) { + if (!silent && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Speichern fehlgeschlagen')), + ); + } + } finally { + _isProjectBusy = false; + } + } + + Future _loadProject({bool silent = false}) async { + if (_isProjectBusy) return; + _isProjectBusy = true; + try { + final file = await _projectFile(); + 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); + + 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; + + final presetName = data['selectedPreset'] as String?; + _selectedPreset = StylePreset.values.firstWhere( + (p) => p.name == presetName, + 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; + } + }); + + if (source != null) { + await _generate(); + } + if (!silent && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Projekt geladen ✅')), + ); + } + } catch (_) { + if (!silent && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Laden fehlgeschlagen')), + ); + } + } finally { + _isProjectBusy = false; + } + } + Future _loadCatalog() async { final defaults = [ CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)), @@ -211,6 +327,7 @@ class _MosaicHomePageState extends State _sourceImageBytes = bytes; _result = null; }); + await _saveProject(silent: true); } Future _pendingCaptureFile() async { @@ -695,6 +812,7 @@ class _MosaicHomePageState extends State ); _isGenerating = false; }); + await _saveProject(silent: true); } @override @@ -758,18 +876,58 @@ class _MosaicHomePageState extends State child: ListView( children: [ _GlassCard( - child: Wrap( - runSpacing: 10, - spacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - FilledButton.icon( - onPressed: _pickImage, - icon: const Icon(Icons.image_outlined), - label: const Text('Import target image'), + Wrap( + runSpacing: 10, + spacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + 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 Chip(label: Text('Image loaded ✅')), + ], ), - 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(height: 6), + Text( + 'Pinch zum Zoomen, mit einem Finger verschieben', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ], ), ), @@ -805,8 +963,8 @@ class _MosaicHomePageState extends State child: TextField( controller: _gridHeightCtrl, keyboardType: TextInputType.number, - decoration: - const InputDecoration(labelText: 'Grid Height')), + decoration: const InputDecoration( + labelText: 'Grid Height')), ), ], ) @@ -825,13 +983,20 @@ class _MosaicHomePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Style Preset', style: Theme.of(context).textTheme.titleMedium), + 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')), + 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), @@ -882,7 +1047,8 @@ class _MosaicHomePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Preview (${_result!.width} x ${_result!.height})', style: Theme.of(context).textTheme.titleMedium), + Text('Preview (${_result!.width} x ${_result!.height})', + style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(18), @@ -904,7 +1070,8 @@ class _MosaicHomePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Bill of Materials', style: Theme.of(context).textTheme.titleMedium), + Text('Bill of Materials', + style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 6), ..._result!.sortedCounts.map( (e) => ListTile( @@ -1053,8 +1220,8 @@ class _MosaicHomePageState extends State IconButton( visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - constraints: - const BoxConstraints.tightFor(width: 30, height: 30), + constraints: const BoxConstraints.tightFor( + width: 30, height: 30), onPressed: _catalog.length <= 1 ? null : () => _deleteEntry(entry), @@ -1469,7 +1636,8 @@ class _GlassCard extends StatelessWidget { final Widget child; final EdgeInsetsGeometry padding; - const _GlassCard({required this.child, this.padding = const EdgeInsets.all(12)}); + const _GlassCard( + {required this.child, this.padding = const EdgeInsets.all(12)}); @override Widget build(BuildContext context) {