diff --git a/README.md b/README.md index 7fec748..04ad371 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images. - Resolution controls: - explicit grid width/height - or auto grid by approximate cap size in source image pixels -- Cap palette management: - - list caps with name + color - - add color via picker and/or manual hex - - **Deckel fotografieren**: capture a cap with camera and auto-detect its color from a robust center-circle sample (reduced background contamination) - - review detected color preview, edit name + hex, then save as a normal palette entry - - remove caps +- Persistent **Cap Catalog** (local JSON in app documents directory): + - each entry stores `name`, `color` (hex/swatch), and optional preview image path + - survives app restarts +- Catalog management: + - add entries manually (name + hex/color picker + optional photo) + - **Deckel fotografieren**: capture a cap with camera, auto-detect dominant center color, store cropped thumbnail + - dedicated catalog browser with **list/grid** modes + - edit existing entry name/color + - delete entries (with thumbnail cleanup) - Mosaic preview + bill of materials counts per cap color +- Mosaic palette source is always the current catalog entries ## Style controls (new) diff --git a/lib/main.dart b/lib/main.dart index 1bd8c25..9ae29dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; @@ -7,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; void main() { runApp(const KorkenMosaicApp()); @@ -27,6 +30,8 @@ class KorkenMosaicApp extends StatelessWidget { enum StylePreset { realistisch, ausgewogen, kuenstlerisch } +enum CatalogViewMode { list, grid } + class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -50,6 +55,8 @@ class _MosaicHomePageState extends State { bool _isGenerating = false; Timer? _debounceTimer; int _generationToken = 0; + bool _isCatalogLoaded = false; + CatalogViewMode _catalogViewMode = CatalogViewMode.grid; double _fidelityStructure = 0.5; double _ditheringStrength = 0.35; @@ -57,13 +64,7 @@ class _MosaicHomePageState extends State { double _colorVariation = 0.3; StylePreset _selectedPreset = StylePreset.ausgewogen; - final List _palette = [ - CapColor(name: 'White', color: const Color(0xFFF2F2F2)), - CapColor(name: 'Black', color: const Color(0xFF222222)), - CapColor(name: 'Red', color: const Color(0xFFD84343)), - CapColor(name: 'Blue', color: const Color(0xFF3F6FD8)), - CapColor(name: 'Green', color: const Color(0xFF4FAE63)), - ]; + List _catalog = []; @override void initState() { @@ -71,6 +72,7 @@ class _MosaicHomePageState extends State { _gridWidthCtrl.addListener(_scheduleRegenerate); _gridHeightCtrl.addListener(_scheduleRegenerate); _capSizeCtrl.addListener(_scheduleRegenerate); + _loadCatalog(); } @override @@ -84,6 +86,55 @@ class _MosaicHomePageState extends State { super.dispose(); } + Future _catalogFile() async { + final docs = await getApplicationDocumentsDirectory(); + return File('${docs.path}/cap_catalog.json'); + } + + Future _saveThumbnail(Uint8List bytes, String id) async { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory('${docs.path}/cap_thumbs'); + await dir.create(recursive: true); + final file = File('${dir.path}/$id.png'); + await file.writeAsBytes(bytes, flush: true); + return file.path; + } + + Future _loadCatalog() async { + final defaults = [ + CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)), + CapCatalogEntry.newEntry(name: 'Black', color: const Color(0xFF222222)), + CapCatalogEntry.newEntry(name: 'Red', color: const Color(0xFFD84343)), + CapCatalogEntry.newEntry(name: 'Blue', color: const Color(0xFF3F6FD8)), + CapCatalogEntry.newEntry(name: 'Green', color: const Color(0xFF4FAE63)), + ]; + + try { + final file = await _catalogFile(); + if (await file.exists()) { + final jsonRaw = jsonDecode(await file.readAsString()) as List; + _catalog = jsonRaw + .map((e) => CapCatalogEntry.fromJson(e as Map)) + .toList(); + } + if (_catalog.isEmpty) { + _catalog = defaults; + await _persistCatalog(); + } + } catch (_) { + _catalog = defaults; + } + + if (!mounted) return; + setState(() => _isCatalogLoaded = true); + } + + Future _persistCatalog() async { + final file = await _catalogFile(); + final jsonData = jsonEncode(_catalog.map((e) => e.toJson()).toList()); + await file.writeAsString(jsonData, flush: true); + } + Future _pickImage() async { final XFile? picked = await _picker.pickImage(source: ImageSource.gallery); if (picked == null) return; @@ -125,12 +176,8 @@ class _MosaicHomePageState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.memory( - previewBytes, - width: 220, - height: 220, - fit: BoxFit.cover, - ), + child: Image.memory(previewBytes, + width: 220, height: 220, fit: BoxFit.cover), ), const SizedBox(height: 8), Text( @@ -179,23 +226,25 @@ class _MosaicHomePageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Abbrechen'), - ), + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen')), FilledButton( - onPressed: () { + onPressed: () async { final name = _photoCapNameCtrl.text.trim(); if (name.isEmpty) return; final parsed = _parseHex(_photoCapHexCtrl.text); - setState(() { - _palette.add( - CapColor(name: name, color: parsed ?? selected), - ); - }); + final entry = CapCatalogEntry.newEntry( + name: name, color: parsed ?? selected); + entry.imagePath = + await _saveThumbnail(previewBytes, entry.id); + _catalog.add(entry); + await _persistCatalog(); + if (!mounted) return; + setState(() {}); Navigator.pop(ctx); _scheduleRegenerate(); }, - child: const Text('Zur Palette hinzufügen'), + child: const Text('Zum Katalog hinzufügen'), ), ], ); @@ -245,24 +294,111 @@ class _MosaicHomePageState extends State { _scheduleRegenerate(); } - void _addCapDialog() { + Future _addCapDialog() async { Color selected = Colors.orange; final nameCtrl = TextEditingController(); final hexCtrl = TextEditingController(text: _colorToHex(selected)); + String? imagePath; - showDialog( + await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Deckel manuell hinzufügen'), + 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) + setDialogState(() => selected = parsed); + }, + ), + const SizedBox(height: 8), + 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: Text(imagePath == null + ? 'Optionales Foto wählen' + : 'Foto gewählt ✅'), + ), + 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('Cancel')), + FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + if (name.isEmpty) return; + final entry = CapCatalogEntry.newEntry( + name: name, + color: _parseHex(hexCtrl.text) ?? selected, + imagePath: imagePath); + _catalog.add(entry); + await _persistCatalog(); + if (!mounted) return; + setState(() {}); + Navigator.pop(ctx); + _scheduleRegenerate(); + }, + child: const Text('Add'), + ), + ], + ); + }, + ); + }, + ); + } + + Future _editEntry(CapCatalogEntry entry) async { + Color selected = entry.color; + final nameCtrl = TextEditingController(text: entry.name); + final hexCtrl = TextEditingController(text: _colorToHex(entry.color)); + + await showDialog( context: context, builder: (ctx) { return AlertDialog( - title: const Text('Add Bottle Cap Color'), + title: const Text('Deckel bearbeiten'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( - controller: nameCtrl, - decoration: const InputDecoration(labelText: 'Name'), - ), + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Name')), const SizedBox(height: 8), TextField( controller: hexCtrl, @@ -285,23 +421,22 @@ class _MosaicHomePageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen')), FilledButton( - onPressed: () { - final name = nameCtrl.text.trim(); - if (name.isEmpty) return; - setState(() { - _palette.add( - CapColor( - name: name, color: _parseHex(hexCtrl.text) ?? selected), - ); - }); + 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(() {}); Navigator.pop(ctx); _scheduleRegenerate(); }, - child: const Text('Add'), + child: const Text('Speichern'), ), ], ); @@ -309,8 +444,23 @@ class _MosaicHomePageState extends State { ); } + Future _deleteEntry(CapCatalogEntry entry) async { + if (_catalog.length <= 1) return; + _catalog.removeWhere((e) => e.id == entry.id); + if (entry.imagePath != null) { + final f = File(entry.imagePath!); + if (await f.exists()) { + await f.delete(); + } + } + await _persistCatalog(); + if (!mounted) return; + setState(() {}); + _scheduleRegenerate(); + } + Future _generate() async { - if (_sourceImageBytes == null || _palette.isEmpty) return; + if (_sourceImageBytes == null || _catalog.isEmpty) return; final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40); final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30); @@ -329,9 +479,8 @@ class _MosaicHomePageState extends State { 'ditheringStrength': _ditheringStrength, 'edgeEmphasis': _edgeEmphasis, 'colorVariation': _colorVariation, - 'palette': _palette - .map((p) => - {'name': p.name, 'value': p.color.toARGB32()}) + 'palette': _catalog + .map((p) => {'name': p.name, 'value': p.colorValue}) .toList(growable: false), }; @@ -339,10 +488,8 @@ class _MosaicHomePageState extends State { if (!mounted || token != _generationToken) return; final palette = (out['palette'] as List) - .map( - (e) => CapColor( - name: e['name'] as String, color: Color(e['value'] as int)), - ) + .map((e) => CapColor( + name: e['name'] as String, color: Color(e['value'] as int))) .toList(growable: false); final countsList = (out['counts'] as List).cast(); @@ -378,221 +525,293 @@ class _MosaicHomePageState extends State { ? const SizedBox( width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) + child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.auto_fix_high), label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), ), - body: 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( - 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( + body: !_isCatalogLoaded + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(12), + child: ListView( children: [ - Expanded( - child: TextField( - controller: _gridWidthCtrl, + 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( + 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, keyboardType: TextInputType.number, - decoration: - const InputDecoration(labelText: 'Grid Width'), + 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( + 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(width: 8), - Expanded( - child: TextField( - controller: _gridHeightCtrl, - keyboardType: TextInputType.number, - decoration: - const InputDecoration(labelText: 'Grid Height'), - ), + 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: _captureCapPhoto, + icon: const Icon(Icons.photo_camera_outlined), + label: const Text('Foto')), + const SizedBox(width: 8), + IconButton( + onPressed: _addCapDialog, + icon: const Icon(Icons.add_circle_outline), + tooltip: 'Manuell hinzufügen'), + ], ), - ], - ) - 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( - 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(); - }, + 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), + ), ), - _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), + 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), - Row( - children: [ - const Expanded( - child: Text( - 'Cap Palette', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - OutlinedButton.icon( - onPressed: _captureCapPhoto, - icon: const Icon(Icons.photo_camera_outlined), - label: const Text('Deckel fotografieren'), - ), - const SizedBox(width: 8), - IconButton( - onPressed: _addCapDialog, - icon: const Icon(Icons.add_circle_outline), - tooltip: 'Farbe manuell hinzufügen', - ), - ], - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (int i = 0; i < _palette.length; i++) - Chip( - avatar: CircleAvatar(backgroundColor: _palette[i].color), - label: Text(_palette[i].name), - onDeleted: _palette.length <= 1 - ? null - : () { - setState(() => _palette.removeAt(i)); - _scheduleRegenerate(); - }, - ), - ], - ), - 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, + ); + } + + Widget _buildCatalogView() { + if (_catalog.isEmpty) return const Text('Noch keine Deckel im Katalog'); + + if (_catalogViewMode == CatalogViewMode.list) { + return Column( + children: _catalog + .map( + (entry) => Card( + child: ListTile( + leading: _CapThumb(entry: entry), + 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), + ), + ], ), ), ), - 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'), - ), - ), - ], - ], - ), + ) + .toList(), + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _catalog.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), + itemBuilder: (context, index) { + final entry = _catalog[index]; + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _editEntry(entry), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _CapThumb(entry: entry, large: true)), + const SizedBox(height: 6), + Text(entry.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + 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(), + IconButton( + visualDensity: VisualDensity.compact, + onPressed: _catalog.length <= 1 + ? null + : () => _deleteEntry(entry), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ], + ), + ), + ), + ); + }, ); } @@ -609,6 +828,36 @@ class _MosaicHomePageState extends State { } } +class _CapThumb extends StatelessWidget { + final CapCatalogEntry entry; + final bool large; + + const _CapThumb({required this.entry, this.large = false}); + + @override + Widget build(BuildContext context) { + final size = large ? double.infinity : 42.0; + final radius = BorderRadius.circular(large ? 12 : 8); + + if (entry.imagePath != null && File(entry.imagePath!).existsSync()) { + return ClipRRect( + borderRadius: radius, + child: Image.file(File(entry.imagePath!), + width: size, height: size, fit: BoxFit.cover), + ); + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: entry.color, + borderRadius: radius, + border: Border.all(color: Colors.black12)), + ); + } +} + class _SliderRow extends StatelessWidget { final String label; final String leftLabel; @@ -646,6 +895,46 @@ class _SliderRow extends StatelessWidget { } } +class CapCatalogEntry { + final String id; + String name; + int colorValue; + String? imagePath; + + CapCatalogEntry( + {required this.id, + required this.name, + required this.colorValue, + this.imagePath}); + + factory CapCatalogEntry.newEntry( + {required String name, required Color color, String? imagePath}) { + return CapCatalogEntry( + id: DateTime.now().microsecondsSinceEpoch.toString(), + name: name, + colorValue: color.toARGB32(), + imagePath: imagePath, + ); + } + + Color get color => Color(colorValue); + + Map toJson() => { + 'id': id, + 'name': name, + 'colorValue': colorValue, + 'imagePath': imagePath, + }; + + factory CapCatalogEntry.fromJson(Map json) => + CapCatalogEntry( + id: json['id'] as String, + name: json['name'] as String, + colorValue: json['colorValue'] as int, + imagePath: json['imagePath'] as String?, + ); +} + class CapColor { final String name; final Color color; @@ -693,9 +982,8 @@ Map _generateMosaicIsolate(Map request) { 'assignments': [0], 'counts': [1], 'palette': paletteRaw, - 'previewPng': Uint8List.fromList( - img.encodePng(img.Image(width: 1, height: 1)), - ), + 'previewPng': + Uint8List.fromList(img.encodePng(img.Image(width: 1, height: 1))), }; } @@ -709,12 +997,8 @@ Map _generateMosaicIsolate(Map request) { final interpolation = fidelityStructure < 0.4 ? img.Interpolation.average : img.Interpolation.linear; - final scaled = img.copyResize( - decoded, - width: gridW, - height: gridH, - interpolation: interpolation, - ); + final scaled = img.copyResize(decoded, + width: gridW, height: gridH, interpolation: interpolation); final pixelCount = gridW * gridH; final workingR = List.filled(pixelCount, 0); @@ -897,13 +1181,7 @@ Map _generateMosaicIsolate(Map request) { final idx = assignments[y * gridW + x]; final argb = paletteValues[idx]; preview.setPixelRgba( - x, - y, - (argb >> 16) & 0xFF, - (argb >> 8) & 0xFF, - argb & 0xFF, - 255, - ); + x, y, (argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF, 255); } } @@ -923,21 +1201,15 @@ Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { return { 'color': Colors.orange.toARGB32(), 'previewPng': Uint8List.fromList( - img.encodePng(img.Image(width: 1, height: 1), level: 1), - ), + img.encodePng(img.Image(width: 1, height: 1), level: 1)), }; } final cropSize = math.min(decoded.width, decoded.height); final startX = (decoded.width - cropSize) ~/ 2; final startY = (decoded.height - cropSize) ~/ 2; - final centered = img.copyCrop( - decoded, - x: startX, - y: startY, - width: cropSize, - height: cropSize, - ); + final centered = img.copyCrop(decoded, + x: startX, y: startY, width: cropSize, height: cropSize); final analysisSize = centered.width > 420 ? img.copyResize(centered, width: 420, height: 420) @@ -985,19 +1257,16 @@ Map _extractCapFromCenterIsolate(Uint8List sourceBytes) { } if (dominant == null || dominant.count < included * 0.08) { - resultArgb = Color.fromARGB( - 255, - (sumR / included).round(), - (sumG / included).round(), - (sumB / included).round(), - ).toARGB32(); + resultArgb = Color.fromARGB(255, (sumR / included).round(), + (sumG / included).round(), (sumB / included).round()) + .toARGB32(); } else { resultArgb = Color.fromARGB( - 255, - (dominant.r / dominant.count).round(), - (dominant.g / dominant.count).round(), - (dominant.b / dominant.count).round(), - ).toARGB32(); + 255, + (dominant.r / dominant.count).round(), + (dominant.g / dominant.count).round(), + (dominant.b / dominant.count).round()) + .toARGB32(); } } diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..2d027aa --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,578 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index e50876b..6d142aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: image_picker: ^1.1.2 image: ^4.2.0 flutter_colorpicker: ^1.1.0 + path_provider: ^2.1.5 dev_dependencies: flutter_test: