From 9edaef23ff680add4fd80ae7cb5ea3a8bba0264f Mon Sep 17 00:00:00 2001 From: gary Date: Sun, 22 Feb 2026 02:01:45 +0100 Subject: [PATCH] Separate catalog screen and show photo+color together --- lib/main.dart | 683 +++++++++++++++++++++++++++++--------------------- 1 file changed, 402 insertions(+), 281 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 73ed5eb..563dae9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,8 @@ enum CatalogViewMode { list, grid } enum ColorExtractionMode { dominant, average } +enum HomeSection { mosaic, catalog } + class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -57,6 +59,7 @@ class _MosaicHomePageState extends State Timer? _debounceTimer; int _generationToken = 0; bool _isCatalogLoaded = false; + HomeSection _activeSection = HomeSection.mosaic; CatalogViewMode _catalogViewMode = CatalogViewMode.grid; ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant; bool _isCaptureFlowInProgress = false; @@ -447,61 +450,128 @@ class _MosaicHomePageState extends State Color selected = entry.color; final nameCtrl = TextEditingController(text: entry.name); final hexCtrl = TextEditingController(text: _colorToHex(entry.color)); + String? imagePath = entry.imagePath; await showDialog( context: context, builder: (ctx) { - return AlertDialog( - title: const Text('Deckel bearbeiten'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: 'Name')), - const SizedBox(height: 8), - TextField( - controller: hexCtrl, - decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), - onChanged: (value) { - final parsed = _parseHex(value); - if (parsed != null) selected = parsed; - }, - ), - const SizedBox(height: 12), - ColorPicker( - pickerColor: selected, - onColorChanged: (c) { - selected = c; - hexCtrl.text = _colorToHex(c); - }, - ), - ], + return StatefulBuilder(builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Deckel bearbeiten'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (imagePath != null && File(imagePath!).existsSync()) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(imagePath!), + height: 160, + fit: BoxFit.cover, + ), + ) + else + Container( + height: 160, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + Theme.of(ctx).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.photo_outlined, size: 40), + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: () async { + final picked = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + imageQuality: 90, + ); + if (picked == null) return; + imagePath = picked.path; + setDialogState(() {}); + }, + icon: const Icon(Icons.image_outlined), + label: const Text('Foto ändern'), + ), + const SizedBox(width: 8), + if (imagePath != null) + TextButton( + onPressed: () { + imagePath = null; + setDialogState(() {}); + }, + child: const Text('Foto entfernen'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Name')), + const SizedBox(height: 8), + TextField( + controller: hexCtrl, + decoration: + const InputDecoration(labelText: 'Hex (#RRGGBB)'), + onChanged: (value) { + final parsed = _parseHex(value); + if (parsed != null) { + selected = parsed; + setDialogState(() {}); + } + }, + ), + const SizedBox(height: 8), + Row( + children: [ + CircleAvatar(radius: 14, backgroundColor: selected), + const SizedBox(width: 8), + Text(_colorToHex(selected)), + ], + ), + const SizedBox(height: 12), + ColorPicker( + pickerColor: selected, + onColorChanged: (c) { + selected = c; + hexCtrl.text = _colorToHex(c); + setDialogState(() {}); + }, + ), + ], + ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Abbrechen')), - FilledButton( - onPressed: () async { - entry.name = nameCtrl.text.trim().isEmpty - ? entry.name - : nameCtrl.text.trim(); - entry.colorValue = - (_parseHex(hexCtrl.text) ?? selected).toARGB32(); - await _persistCatalog(); - if (!mounted) return; - setState(() {}); - if (!ctx.mounted) return; - Navigator.pop(ctx); - _scheduleRegenerate(); - }, - child: const Text('Speichern'), - ), - ], - ); + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen')), + FilledButton( + onPressed: () async { + entry.name = nameCtrl.text.trim().isEmpty + ? entry.name + : nameCtrl.text.trim(); + entry.colorValue = + (_parseHex(hexCtrl.text) ?? selected).toARGB32(); + entry.imagePath = imagePath; + await _persistCatalog(); + if (!mounted) return; + setState(() {}); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _scheduleRegenerate(); + }, + child: const Text('Speichern'), + ), + ], + ); + }); }, ); } @@ -580,224 +650,249 @@ class _MosaicHomePageState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')), - floatingActionButton: FloatingActionButton.extended( - onPressed: _isGenerating ? null : _generate, - icon: _isGenerating - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.auto_fix_high), - label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), + appBar: AppBar( + title: Text(_activeSection == HomeSection.mosaic + ? 'Bottle-Cap Mosaic Prototype' + : 'Cap Catalog'), ), + floatingActionButton: _activeSection == HomeSection.mosaic + ? FloatingActionButton.extended( + onPressed: _isGenerating ? null : _generate, + icon: _isGenerating + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high), + label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), + ) + : null, body: !_isCatalogLoaded ? const Center(child: CircularProgressIndicator()) - : Padding( - padding: const EdgeInsets.all(12), - child: ListView( - children: [ - Wrap( - runSpacing: 8, - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - FilledButton.icon( - onPressed: _pickImage, - icon: const Icon(Icons.image_outlined), - label: const Text('Import target image'), - ), - if (_sourceImageBytes != null) - const Text('Image loaded ✅'), - ], - ), - const SizedBox(height: 12), - 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, + : _activeSection == HomeSection.mosaic + ? _buildMosaicScreen() + : _buildCatalogScreen(), + bottomNavigationBar: NavigationBar( + selectedIndex: _activeSection.index, + onDestinationSelected: (index) { + setState(() => _activeSection = HomeSection.values[index]); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.auto_awesome_outlined), + selectedIcon: Icon(Icons.auto_awesome), + label: 'Mosaic', + ), + NavigationDestination( + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), + label: 'Catalog', + ), + ], + ), + ); + } + + Widget _buildMosaicScreen() { + return Padding( + padding: const EdgeInsets.all(12), + child: ListView( + children: [ + Wrap( + runSpacing: 8, + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: _pickImage, + icon: const Icon(Icons.image_outlined), + label: const Text('Import target image'), + ), + if (_sourceImageBytes != null) const Text('Image loaded ✅'), + ], + ), + const SizedBox(height: 12), + 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: - 'Approx cap size in source image (pixels)'), - ), - const SizedBox(height: 16), - const Text('Style Preset', - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - 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), - Card( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Column( - children: [ - _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: 16), - Row( - children: [ - const Expanded( - child: Text('Cap Catalog', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold))), - IconButton( - onPressed: () => setState( - () => _catalogViewMode = CatalogViewMode.list), - icon: Icon(Icons.view_list, - color: _catalogViewMode == CatalogViewMode.list - ? Theme.of(context).colorScheme.primary - : null), - tooltip: 'Listenansicht', - ), - IconButton( - onPressed: () => setState( - () => _catalogViewMode = CatalogViewMode.grid), - icon: Icon(Icons.grid_view, - color: _catalogViewMode == CatalogViewMode.grid - ? Theme.of(context).colorScheme.primary - : null), - tooltip: 'Rasteransicht', - ), - OutlinedButton.icon( - onPressed: _isCaptureFlowInProgress - ? null - : _captureCapPhoto, - icon: _isCaptureFlowInProgress - ? const SizedBox( - width: 16, - height: 16, - child: - CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.photo_camera_outlined), - label: Text( - _isCaptureFlowInProgress ? 'Läuft…' : 'Foto')), - const SizedBox(width: 8), - IconButton( - onPressed: _addCapDialog, - icon: const Icon(Icons.add_circle_outline), - tooltip: 'Manuell hinzufügen'), - ], - ), - const SizedBox(height: 8), - _buildCatalogView(), - const SizedBox(height: 16), - if (_result != null) ...[ - Text('Preview (${_result!.width} x ${_result!.height})', - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - RepaintBoundary( - child: AspectRatio( - aspectRatio: _result!.width / _result!.height, - child: Image.memory(_result!.previewPng, - fit: BoxFit.fill, - filterQuality: FilterQuality.none, - gaplessPlayback: true), - ), - ), - const SizedBox(height: 16), - const Text('Bill of Materials', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - ..._result!.sortedCounts.map( - (e) => ListTile( - dense: true, - leading: CircleAvatar(backgroundColor: e.key.color), - title: Text(e.key.name), - trailing: Text('${e.value} caps'), - ), - ), - ], + 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)'), + ), + const SizedBox(height: 16), + const Text('Style Preset', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + 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), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + _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: 16), + if (_result != null) ...[ + Text('Preview (${_result!.width} x ${_result!.height})', + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + RepaintBoundary( + child: AspectRatio( + aspectRatio: _result!.width / _result!.height, + child: Image.memory(_result!.previewPng, + fit: BoxFit.fill, + filterQuality: FilterQuality.none, + gaplessPlayback: true), + ), + ), + const SizedBox(height: 16), + const Text('Bill of Materials', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ..._result!.sortedCounts.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(12), + child: ListView( + children: [ + Row( + children: [ + IconButton( + onPressed: () => + setState(() => _catalogViewMode = CatalogViewMode.list), + icon: Icon(Icons.view_list, + color: _catalogViewMode == CatalogViewMode.list + ? Theme.of(context).colorScheme.primary + : null), + tooltip: 'Listenansicht', + ), + IconButton( + onPressed: () => + setState(() => _catalogViewMode = CatalogViewMode.grid), + icon: Icon(Icons.grid_view, + color: _catalogViewMode == CatalogViewMode.grid + ? Theme.of(context).colorScheme.primary + : null), + tooltip: 'Rasteransicht', + ), + const Spacer(), + OutlinedButton.icon( + onPressed: _isCaptureFlowInProgress ? null : _captureCapPhoto, + icon: _isCaptureFlowInProgress + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.photo_camera_outlined), + label: Text(_isCaptureFlowInProgress ? 'Läuft…' : 'Foto')), + const SizedBox(width: 8), + IconButton( + onPressed: _addCapDialog, + icon: const Icon(Icons.add_circle_outline), + tooltip: 'Manuell hinzufügen'), + ], + ), + const SizedBox(height: 8), + _buildCatalogView(), + ], + ), ); } @@ -810,23 +905,23 @@ class _MosaicHomePageState extends State .map( (entry) => Card( child: ListTile( - leading: _CapThumb(entry: entry), + onTap: () => _editEntry(entry), + leading: SizedBox( + width: 88, + child: Row( + children: [ + _CapThumb(entry: entry), + const SizedBox(width: 8), + _ColorSwatch(color: entry.color), + ], + ), + ), title: Text(entry.name), subtitle: Text(_colorToHex(entry.color)), - trailing: Wrap( - spacing: 4, - children: [ - CircleAvatar(radius: 10, backgroundColor: entry.color), - IconButton( - onPressed: () => _editEntry(entry), - icon: const Icon(Icons.edit_outlined)), - IconButton( - onPressed: _catalog.length <= 1 - ? null - : () => _deleteEntry(entry), - icon: const Icon(Icons.delete_outline), - ), - ], + trailing: IconButton( + onPressed: + _catalog.length <= 1 ? null : () => _deleteEntry(entry), + icon: const Icon(Icons.delete_outline), ), ), ), @@ -856,7 +951,13 @@ class _MosaicHomePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _CapThumb(entry: entry, large: true)), + Row( + children: [ + Expanded(child: _CapThumb(entry: entry, large: true)), + const SizedBox(width: 8), + _ColorSwatch(color: entry.color, large: true), + ], + ), const SizedBox(height: 6), Text(entry.name, maxLines: 1, @@ -864,8 +965,6 @@ class _MosaicHomePageState extends State style: const TextStyle(fontWeight: FontWeight.w600)), Row( children: [ - CircleAvatar(radius: 8, backgroundColor: entry.color), - const SizedBox(width: 6), Text(_colorToHex(entry.color), style: Theme.of(context).textTheme.bodySmall), const Spacer(), @@ -1220,7 +1319,7 @@ class _CapThumb extends StatelessWidget { @override Widget build(BuildContext context) { - final size = large ? double.infinity : 42.0; + final size = large ? 84.0 : 42.0; final radius = BorderRadius.circular(large ? 12 : 8); if (entry.imagePath != null && File(entry.imagePath!).existsSync()) { @@ -1235,9 +1334,31 @@ class _CapThumb extends StatelessWidget { width: size, height: size, decoration: BoxDecoration( - color: entry.color, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: radius, border: Border.all(color: Colors.black12)), + child: const Icon(Icons.photo_outlined), + ); + } +} + +class _ColorSwatch extends StatelessWidget { + final Color color; + final bool large; + + const _ColorSwatch({required this.color, this.large = false}); + + @override + Widget build(BuildContext context) { + final size = large ? 58.0 : 26.0; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(large ? 12 : 999), + border: Border.all(color: Colors.black26), + ), ); } }