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,34 +450,99 @@ 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 StatefulBuilder(builder: (ctx, setDialogState) {
return AlertDialog( return AlertDialog(
title: const Text('Deckel bearbeiten'), title: const Text('Deckel bearbeiten'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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( TextField(
controller: nameCtrl, controller: nameCtrl,
decoration: const InputDecoration(labelText: 'Name')), decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: hexCtrl, controller: hexCtrl,
decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), decoration:
const InputDecoration(labelText: 'Hex (#RRGGBB)'),
onChanged: (value) { onChanged: (value) {
final parsed = _parseHex(value); final parsed = _parseHex(value);
if (parsed != null) selected = parsed; 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), const SizedBox(height: 12),
ColorPicker( ColorPicker(
pickerColor: selected, pickerColor: selected,
onColorChanged: (c) { onColorChanged: (c) {
selected = c; selected = c;
hexCtrl.text = _colorToHex(c); hexCtrl.text = _colorToHex(c);
setDialogState(() {});
}, },
), ),
], ],
@@ -491,6 +559,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
: 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(() {});
@@ -502,6 +571,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
), ),
], ],
); );
});
}, },
); );
} }
@@ -580,8 +650,13 @@ 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
? 'Bottle-Cap Mosaic Prototype'
: 'Cap Catalog'),
),
floatingActionButton: _activeSection == HomeSection.mosaic
? FloatingActionButton.extended(
onPressed: _isGenerating ? null : _generate, onPressed: _isGenerating ? null : _generate,
icon: _isGenerating icon: _isGenerating
? const SizedBox( ? const SizedBox(
@@ -590,10 +665,36 @@ class _MosaicHomePageState extends State<MosaicHomePage>
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_fix_high), : const Icon(Icons.auto_fix_high),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
), )
: null,
body: !_isCatalogLoaded body: !_isCatalogLoaded
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: Padding( : _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), padding: const EdgeInsets.all(12),
child: ListView( child: ListView(
children: [ children: [
@@ -607,8 +708,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
icon: const Icon(Icons.image_outlined), icon: const Icon(Icons.image_outlined),
label: const Text('Import target image'), label: const Text('Import target image'),
), ),
if (_sourceImageBytes != null) if (_sourceImageBytes != null) const Text('Image loaded ✅'),
const Text('Image loaded ✅'),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -631,16 +731,16 @@ class _MosaicHomePageState extends State<MosaicHomePage>
child: TextField( child: TextField(
controller: _gridWidthCtrl, controller: _gridWidthCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration:
labelText: 'Grid Width')), const InputDecoration(labelText: 'Grid Width')),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _gridHeightCtrl, controller: _gridHeightCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration:
labelText: 'Grid Height')), const InputDecoration(labelText: 'Grid Height')),
), ),
], ],
) )
@@ -649,22 +749,18 @@ class _MosaicHomePageState extends State<MosaicHomePage>
controller: _capSizeCtrl, controller: _capSizeCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: labelText: 'Approx cap size in source image (pixels)'),
'Approx cap size in source image (pixels)'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Style Preset', const Text('Style Preset',
style: style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
SegmentedButton<StylePreset>( SegmentedButton<StylePreset>(
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: StylePreset.realistisch, value: StylePreset.realistisch, label: Text('Realistisch')),
label: Text('Realistisch')),
ButtonSegment( ButtonSegment(
value: StylePreset.ausgewogen, value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
label: Text('Ausgewogen')),
ButtonSegment( ButtonSegment(
value: StylePreset.kuenstlerisch, value: StylePreset.kuenstlerisch,
label: Text('Künstlerisch')), label: Text('Künstlerisch')),
@@ -675,8 +771,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
const SizedBox(height: 8), const SizedBox(height: 8),
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
horizontal: 12, vertical: 8),
child: Column( child: Column(
children: [ children: [
_SliderRow( _SliderRow(
@@ -720,58 +815,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
), ),
), ),
const SizedBox(height: 16), 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) ...[ if (_result != null) ...[
Text('Preview (${_result!.width} x ${_result!.height})', Text('Preview (${_result!.width} x ${_result!.height})',
style: const TextStyle( style:
fontSize: 18, fontWeight: FontWeight.bold)), const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
RepaintBoundary( RepaintBoundary(
child: AspectRatio( child: AspectRatio(
@@ -784,8 +831,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Bill of Materials', const Text('Bill of Materials',
style: TextStyle( style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
fontSize: 18, fontWeight: FontWeight.bold)),
..._result!.sortedCounts.map( ..._result!.sortedCounts.map(
(e) => ListTile( (e) => ListTile(
dense: true, dense: true,
@@ -797,6 +843,55 @@ class _MosaicHomePageState extends State<MosaicHomePage>
], ],
], ],
), ),
);
}
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,24 +905,24 @@ 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),
IconButton(
onPressed: () => _editEntry(entry),
icon: const Icon(Icons.edit_outlined)),
IconButton(
onPressed: _catalog.length <= 1
? null
: () => _deleteEntry(entry),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
), ),
],
),
), ),
), ),
) )
@@ -855,8 +950,14 @@ class _MosaicHomePageState extends State<MosaicHomePage>
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Expanded(child: _CapThumb(entry: entry, large: true)), 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),
),
); );
} }
} }