Separate catalog screen and show photo+color together
This commit is contained in:
305
lib/main.dart
305
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<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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user