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 HomeSection { mosaic, catalog }
class MosaicHomePage extends StatefulWidget {
const MosaicHomePage({super.key});
@@ -57,6 +59,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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<MosaicHomePage>
Color selected = entry.color;
final nameCtrl = TextEditingController(text: entry.name);
final hexCtrl = TextEditingController(text: _colorToHex(entry.color));
String? imagePath = entry.imagePath;
await showDialog<void>(
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<MosaicHomePage>
@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<bool>(
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<bool>(
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<StylePreset>(
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<StylePreset>(
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<MosaicHomePage>
.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<MosaicHomePage>
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<MosaicHomePage>
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),
),
);
}
}