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,34 +450,99 @@ 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 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)'),
decoration:
const InputDecoration(labelText: 'Hex (#RRGGBB)'),
onChanged: (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),
ColorPicker(
pickerColor: selected,
onColorChanged: (c) {
selected = c;
hexCtrl.text = _colorToHex(c);
setDialogState(() {});
},
),
],
@@ -491,6 +559,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
: nameCtrl.text.trim();
entry.colorValue =
(_parseHex(hexCtrl.text) ?? selected).toARGB32();
entry.imagePath = imagePath;
await _persistCatalog();
if (!mounted) return;
setState(() {});
@@ -502,6 +571,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
),
],
);
});
},
);
}
@@ -580,8 +650,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')),
floatingActionButton: FloatingActionButton.extended(
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(
@@ -590,10 +665,36 @@ class _MosaicHomePageState extends State<MosaicHomePage>
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_fix_high),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
),
)
: null,
body: !_isCatalogLoaded
? 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),
child: ListView(
children: [
@@ -607,8 +708,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
icon: const Icon(Icons.image_outlined),
label: const Text('Import target image'),
),
if (_sourceImageBytes != null)
const Text('Image loaded ✅'),
if (_sourceImageBytes != null) const Text('Image loaded ✅'),
],
),
const SizedBox(height: 12),
@@ -631,16 +731,16 @@ class _MosaicHomePageState extends State<MosaicHomePage>
child: TextField(
controller: _gridWidthCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Grid Width')),
decoration:
const InputDecoration(labelText: 'Grid Width')),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _gridHeightCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Grid Height')),
decoration:
const InputDecoration(labelText: 'Grid Height')),
),
],
)
@@ -649,22 +749,18 @@ class _MosaicHomePageState extends State<MosaicHomePage>
controller: _capSizeCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText:
'Approx cap size in source image (pixels)'),
labelText: 'Approx cap size in source image (pixels)'),
),
const SizedBox(height: 16),
const Text('Style Preset',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SegmentedButton<StylePreset>(
segments: const [
ButtonSegment(
value: StylePreset.realistisch,
label: Text('Realistisch')),
value: StylePreset.realistisch, label: Text('Realistisch')),
ButtonSegment(
value: StylePreset.ausgewogen,
label: Text('Ausgewogen')),
value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
ButtonSegment(
value: StylePreset.kuenstlerisch,
label: Text('Künstlerisch')),
@@ -675,8 +771,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: [
_SliderRow(
@@ -720,58 +815,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
),
),
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)),
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
RepaintBoundary(
child: AspectRatio(
@@ -784,8 +831,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
),
const SizedBox(height: 16),
const Text('Bill of Materials',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
..._result!.sortedCounts.map(
(e) => ListTile(
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(
(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),
trailing: IconButton(
onPressed:
_catalog.length <= 1 ? null : () => _deleteEntry(entry),
icon: const Icon(Icons.delete_outline),
),
],
),
),
),
)
@@ -855,8 +950,14 @@ class _MosaicHomePageState extends State<MosaicHomePage>
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
),
);
}
}