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