Separate catalog screen and show photo+color together

This commit is contained in:
gary
2026-02-22 02:01:45 +01:00
parent 4cc7de158e
commit 9edaef23ff

View File

@@ -33,6 +33,8 @@ enum CatalogViewMode { list, grid }
enum ColorExtractionMode { dominant, average } enum ColorExtractionMode { dominant, average }
enum HomeSection { mosaic, catalog }
class MosaicHomePage extends StatefulWidget { class MosaicHomePage extends StatefulWidget {
const MosaicHomePage({super.key}); const MosaicHomePage({super.key});
@@ -57,6 +59,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Timer? _debounceTimer; Timer? _debounceTimer;
int _generationToken = 0; int _generationToken = 0;
bool _isCatalogLoaded = false; bool _isCatalogLoaded = false;
HomeSection _activeSection = HomeSection.mosaic;
CatalogViewMode _catalogViewMode = CatalogViewMode.grid; CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant; ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
bool _isCaptureFlowInProgress = false; bool _isCaptureFlowInProgress = false;
@@ -447,61 +450,128 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Color selected = entry.color; Color selected = entry.color;
final nameCtrl = TextEditingController(text: entry.name); final nameCtrl = TextEditingController(text: entry.name);
final hexCtrl = TextEditingController(text: _colorToHex(entry.color)); final hexCtrl = TextEditingController(text: _colorToHex(entry.color));
String? imagePath = entry.imagePath;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
return AlertDialog( return StatefulBuilder(builder: (ctx, setDialogState) {
title: const Text('Deckel bearbeiten'), return AlertDialog(
content: SingleChildScrollView( title: const Text('Deckel bearbeiten'),
child: Column( content: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
TextField( crossAxisAlignment: CrossAxisAlignment.stretch,
controller: nameCtrl, children: [
decoration: const InputDecoration(labelText: 'Name')), if (imagePath != null && File(imagePath!).existsSync())
const SizedBox(height: 8), ClipRRect(
TextField( borderRadius: BorderRadius.circular(12),
controller: hexCtrl, child: Image.file(
decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), File(imagePath!),
onChanged: (value) { height: 160,
final parsed = _parseHex(value); fit: BoxFit.cover,
if (parsed != null) selected = parsed; ),
}, )
), else
const SizedBox(height: 12), Container(
ColorPicker( height: 160,
pickerColor: selected, alignment: Alignment.center,
onColorChanged: (c) { decoration: BoxDecoration(
selected = c; color:
hexCtrl.text = _colorToHex(c); 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: [
actions: [ TextButton(
TextButton( onPressed: () => Navigator.pop(ctx),
onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
child: const Text('Abbrechen')), FilledButton(
FilledButton( onPressed: () async {
onPressed: () async { entry.name = nameCtrl.text.trim().isEmpty
entry.name = nameCtrl.text.trim().isEmpty ? entry.name
? entry.name : nameCtrl.text.trim();
: nameCtrl.text.trim(); entry.colorValue =
entry.colorValue = (_parseHex(hexCtrl.text) ?? selected).toARGB32();
(_parseHex(hexCtrl.text) ?? selected).toARGB32(); entry.imagePath = imagePath;
await _persistCatalog(); await _persistCatalog();
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
if (!ctx.mounted) return; if (!ctx.mounted) return;
Navigator.pop(ctx); Navigator.pop(ctx);
_scheduleRegenerate(); _scheduleRegenerate();
}, },
child: const Text('Speichern'), child: const Text('Speichern'),
), ),
], ],
); );
});
}, },
); );
} }
@@ -580,224 +650,249 @@ class _MosaicHomePageState extends State<MosaicHomePage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')), appBar: AppBar(
floatingActionButton: FloatingActionButton.extended( title: Text(_activeSection == HomeSection.mosaic
onPressed: _isGenerating ? null : _generate, ? 'Bottle-Cap Mosaic Prototype'
icon: _isGenerating : 'Cap Catalog'),
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_fix_high),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
), ),
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 body: !_isCatalogLoaded
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: Padding( : _activeSection == HomeSection.mosaic
padding: const EdgeInsets.all(12), ? _buildMosaicScreen()
child: ListView( : _buildCatalogScreen(),
children: [ bottomNavigationBar: NavigationBar(
Wrap( selectedIndex: _activeSection.index,
runSpacing: 8, onDestinationSelected: (index) {
spacing: 8, setState(() => _activeSection = HomeSection.values[index]);
crossAxisAlignment: WrapCrossAlignment.center, },
children: [ destinations: const [
FilledButton.icon( NavigationDestination(
onPressed: _pickImage, icon: Icon(Icons.auto_awesome_outlined),
icon: const Icon(Icons.image_outlined), selectedIcon: Icon(Icons.auto_awesome),
label: const Text('Import target image'), label: 'Mosaic',
), ),
if (_sourceImageBytes != null) NavigationDestination(
const Text('Image loaded ✅'), icon: Icon(Icons.inventory_2_outlined),
], selectedIcon: Icon(Icons.inventory_2),
), label: 'Catalog',
const SizedBox(height: 12), ),
SegmentedButton<bool>( ],
segments: const [ ),
ButtonSegment(value: false, label: Text('Grid W x H')), );
ButtonSegment(value: true, label: Text('Cap size (px)')), }
],
selected: {_useCapSize}, Widget _buildMosaicScreen() {
onSelectionChanged: (s) { return Padding(
setState(() => _useCapSize = s.first); padding: const EdgeInsets.all(12),
_scheduleRegenerate(); child: ListView(
}, children: [
), Wrap(
const SizedBox(height: 8), runSpacing: 8,
if (!_useCapSize) spacing: 8,
Row( crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Expanded( FilledButton.icon(
child: TextField( onPressed: _pickImage,
controller: _gridWidthCtrl, icon: const Icon(Icons.image_outlined),
keyboardType: TextInputType.number, label: const Text('Import target image'),
decoration: const InputDecoration( ),
labelText: 'Grid Width')), if (_sourceImageBytes != null) const Text('Image loaded ✅'),
), ],
const SizedBox(width: 8), ),
Expanded( const SizedBox(height: 12),
child: TextField( SegmentedButton<bool>(
controller: _gridHeightCtrl, segments: const [
keyboardType: TextInputType.number, ButtonSegment(value: false, label: Text('Grid W x H')),
decoration: const InputDecoration( ButtonSegment(value: true, label: Text('Cap size (px)')),
labelText: 'Grid Height')), ],
), selected: {_useCapSize},
], onSelectionChanged: (s) {
) setState(() => _useCapSize = s.first);
else _scheduleRegenerate();
TextField( },
controller: _capSizeCtrl, ),
const SizedBox(height: 8),
if (!_useCapSize)
Row(
children: [
Expanded(
child: TextField(
controller: _gridWidthCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration:
labelText: const InputDecoration(labelText: 'Grid Width')),
'Approx cap size in source image (pixels)'), ),
), const SizedBox(width: 8),
const SizedBox(height: 16), Expanded(
const Text('Style Preset', child: TextField(
style: controller: _gridHeightCtrl,
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), keyboardType: TextInputType.number,
const SizedBox(height: 8), decoration:
SegmentedButton<StylePreset>( const InputDecoration(labelText: 'Grid Height')),
segments: const [ ),
ButtonSegment( ],
value: StylePreset.realistisch, )
label: Text('Realistisch')), else
ButtonSegment( TextField(
value: StylePreset.ausgewogen, controller: _capSizeCtrl,
label: Text('Ausgewogen')), keyboardType: TextInputType.number,
ButtonSegment( decoration: const InputDecoration(
value: StylePreset.kuenstlerisch, labelText: 'Approx cap size in source image (pixels)'),
label: Text('Künstlerisch')), ),
], const SizedBox(height: 16),
selected: {_selectedPreset}, const Text('Style Preset',
onSelectionChanged: (s) => _applyPreset(s.first), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
), const SizedBox(height: 8),
const SizedBox(height: 8), SegmentedButton<StylePreset>(
Card( segments: const [
child: Padding( ButtonSegment(
padding: const EdgeInsets.symmetric( value: StylePreset.realistisch, label: Text('Realistisch')),
horizontal: 12, vertical: 8), ButtonSegment(
child: Column( value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
children: [ ButtonSegment(
_SliderRow( value: StylePreset.kuenstlerisch,
label: 'Fidelity ↔ Structure', label: Text('Künstlerisch')),
leftLabel: 'Fidelity', ],
rightLabel: 'Structure', selected: {_selectedPreset},
value: _fidelityStructure, onSelectionChanged: (s) => _applyPreset(s.first),
onChanged: (v) { ),
setState(() => _fidelityStructure = v); const SizedBox(height: 8),
_onStyleChanged(); Card(
}), child: Padding(
_SliderRow( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
label: 'Dithering strength', child: Column(
leftLabel: 'Off', children: [
rightLabel: 'Strong', _SliderRow(
value: _ditheringStrength, label: 'Fidelity ↔ Structure',
onChanged: (v) { leftLabel: 'Fidelity',
setState(() => _ditheringStrength = v); rightLabel: 'Structure',
_onStyleChanged(); value: _fidelityStructure,
}), onChanged: (v) {
_SliderRow( setState(() => _fidelityStructure = v);
label: 'Edge emphasis', _onStyleChanged();
leftLabel: 'Soft', }),
rightLabel: 'Crisp', _SliderRow(
value: _edgeEmphasis, label: 'Dithering strength',
onChanged: (v) { leftLabel: 'Off',
setState(() => _edgeEmphasis = v); rightLabel: 'Strong',
_onStyleChanged(); value: _ditheringStrength,
}), onChanged: (v) {
_SliderRow( setState(() => _ditheringStrength = v);
label: 'Color tolerance / variation', _onStyleChanged();
leftLabel: 'Strict', }),
rightLabel: 'Varied', _SliderRow(
value: _colorVariation, label: 'Edge emphasis',
onChanged: (v) { leftLabel: 'Soft',
setState(() => _colorVariation = v); rightLabel: 'Crisp',
_onStyleChanged(); value: _edgeEmphasis,
}), onChanged: (v) {
], setState(() => _edgeEmphasis = v);
), _onStyleChanged();
), }),
), _SliderRow(
const SizedBox(height: 16), label: 'Color tolerance / variation',
Row( leftLabel: 'Strict',
children: [ rightLabel: 'Varied',
const Expanded( value: _colorVariation,
child: Text('Cap Catalog', onChanged: (v) {
style: TextStyle( setState(() => _colorVariation = v);
fontSize: 18, fontWeight: FontWeight.bold))), _onStyleChanged();
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'),
),
),
],
], ],
), ),
), ),
),
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<MosaicHomePage>
.map( .map(
(entry) => Card( (entry) => Card(
child: ListTile( 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), title: Text(entry.name),
subtitle: Text(_colorToHex(entry.color)), subtitle: Text(_colorToHex(entry.color)),
trailing: Wrap( trailing: IconButton(
spacing: 4, onPressed:
children: [ _catalog.length <= 1 ? null : () => _deleteEntry(entry),
CircleAvatar(radius: 10, backgroundColor: entry.color), icon: const Icon(Icons.delete_outline),
IconButton(
onPressed: () => _editEntry(entry),
icon: const Icon(Icons.edit_outlined)),
IconButton(
onPressed: _catalog.length <= 1
? null
: () => _deleteEntry(entry),
icon: const Icon(Icons.delete_outline),
),
],
), ),
), ),
), ),
@@ -856,7 +951,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 6),
Text(entry.name, Text(entry.name,
maxLines: 1, maxLines: 1,
@@ -864,8 +965,6 @@ class _MosaicHomePageState extends State<MosaicHomePage>
style: const TextStyle(fontWeight: FontWeight.w600)), style: const TextStyle(fontWeight: FontWeight.w600)),
Row( Row(
children: [ children: [
CircleAvatar(radius: 8, backgroundColor: entry.color),
const SizedBox(width: 6),
Text(_colorToHex(entry.color), Text(_colorToHex(entry.color),
style: Theme.of(context).textTheme.bodySmall), style: Theme.of(context).textTheme.bodySmall),
const Spacer(), const Spacer(),
@@ -1220,7 +1319,7 @@ class _CapThumb extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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); final radius = BorderRadius.circular(large ? 12 : 8);
if (entry.imagePath != null && File(entry.imagePath!).existsSync()) { if (entry.imagePath != null && File(entry.imagePath!).existsSync()) {
@@ -1235,9 +1334,31 @@ class _CapThumb extends StatelessWidget {
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: entry.color, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: radius, borderRadius: radius,
border: Border.all(color: Colors.black12)), 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),
),
); );
} }
} }