import 'dart:math' as math; import 'dart:typed_data'; 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'; void main() { runApp(const KorkenMosaicApp()); } class KorkenMosaicApp extends StatelessWidget { const KorkenMosaicApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Korken Mosaic', theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true), home: const MosaicHomePage(), ); } } class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @override State createState() => _MosaicHomePageState(); } class _MosaicHomePageState extends State { final ImagePicker _picker = ImagePicker(); final TextEditingController _gridWidthCtrl = TextEditingController(text: '40'); final TextEditingController _gridHeightCtrl = TextEditingController(text: '30'); final TextEditingController _capSizeCtrl = TextEditingController(text: '12'); Uint8List? _sourceImageBytes; MosaicResult? _result; bool _useCapSize = false; 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)), ]; @override void dispose() { _gridWidthCtrl.dispose(); _gridHeightCtrl.dispose(); _capSizeCtrl.dispose(); super.dispose(); } Future _pickImage() async { final XFile? picked = await _picker.pickImage(source: ImageSource.gallery); if (picked == null) return; final bytes = await picked.readAsBytes(); setState(() { _sourceImageBytes = bytes; _result = null; }); } void _addCapDialog() { Color selected = Colors.orange; final nameCtrl = TextEditingController(); final hexCtrl = TextEditingController(text: '#${selected.toARGB32().toRadixString(16).substring(2).toUpperCase()}'); showDialog( context: context, builder: (ctx) { return AlertDialog( title: const Text('Add Bottle Cap Color'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name'), ), const SizedBox(height: 8), TextField( controller: hexCtrl, decoration: const InputDecoration(labelText: 'Hex (#RRGGBB)'), onChanged: (value) { final parsed = _parseHex(value); if (parsed != null) selected = parsed; }, ), const SizedBox(height: 12), ColorPicker( pickerColor: selected, onColorChanged: (c) { selected = c; hexCtrl.text = '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; }, ), ], ), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), FilledButton( onPressed: () { final name = nameCtrl.text.trim(); if (name.isEmpty) return; setState(() { _palette.add(CapColor(name: name, color: _parseHex(hexCtrl.text) ?? selected)); }); Navigator.pop(ctx); }, child: const Text('Add'), ), ], ); }, ); } void _generate() { if (_sourceImageBytes == null || _palette.isEmpty) return; final decoded = img.decodeImage(_sourceImageBytes!); if (decoded == null) return; final int gridW; final int gridH; if (_useCapSize) { final capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12); gridW = math.max(1, (decoded.width / capSize).round()); gridH = math.max(1, (decoded.height / capSize).round()); } else { gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40); gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30); } final scaled = img.copyResize(decoded, width: gridW, height: gridH, interpolation: img.Interpolation.average); final paletteLab = _palette.map((p) => _rgbToLab(p.color)).toList(); final List assignments = List.filled(gridW * gridH, 0); final counts = {}; for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { final pix = scaled.getPixel(x, y); final srcColor = Color.fromARGB(255, pix.r.toInt(), pix.g.toInt(), pix.b.toInt()); final srcLab = _rgbToLab(srcColor); int bestIdx = 0; double bestDistance = double.infinity; for (int i = 0; i < _palette.length; i++) { final d = _deltaE76(srcLab, paletteLab[i]); if (d < bestDistance) { bestDistance = d; bestIdx = i; } } assignments[y * gridW + x] = bestIdx; final cap = _palette[bestIdx]; counts[cap] = (counts[cap] ?? 0) + 1; } } setState(() { _result = MosaicResult(width: gridW, height: gridH, assignments: assignments, palette: List.of(_palette), counts: counts); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')), floatingActionButton: FloatingActionButton.extended( onPressed: _generate, icon: const Icon(Icons.auto_fix_high), label: const Text('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), ), 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: 'Approx cap size in source image (pixels)'), ), const SizedBox(height: 16), Row( children: [ const Expanded(child: Text('Cap Palette', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), IconButton(onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline)), ], ), 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)), ), ], ), 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), AspectRatio( aspectRatio: _result!.width / _result!.height, child: CustomPaint( painter: MosaicPainter(_result!), ), ), const SizedBox(height: 16), const Text('Bill of Materials', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ...(() { final items = _result!.counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); return items .map( (e) => ListTile( dense: true, leading: CircleAvatar(backgroundColor: e.key.color), title: Text(e.key.name), trailing: Text('${e.value} caps'), ), ) .toList(); })(), ], ], ), ), ); } Color? _parseHex(String raw) { final hex = raw.trim().replaceFirst('#', ''); if (hex.length != 6) return null; final value = int.tryParse(hex, radix: 16); if (value == null) return null; return Color(0xFF000000 | value); } } class CapColor { final String name; final Color color; CapColor({required this.name, required this.color}); } class MosaicResult { final int width; final int height; final List assignments; final List palette; final Map counts; MosaicResult({required this.width, required this.height, required this.assignments, required this.palette, required this.counts}); } class MosaicPainter extends CustomPainter { final MosaicResult result; MosaicPainter(this.result); @override void paint(Canvas canvas, Size size) { final cellW = size.width / result.width; final cellH = size.height / result.height; final paint = Paint(); for (int y = 0; y < result.height; y++) { for (int x = 0; x < result.width; x++) { final idx = result.assignments[y * result.width + x]; paint.color = result.palette[idx].color; canvas.drawRect(Rect.fromLTWH(x * cellW, y * cellH, cellW, cellH), paint); } } } @override bool shouldRepaint(covariant MosaicPainter oldDelegate) => oldDelegate.result != result; } List _rgbToLab(Color c) { double r = c.r / 255.0; double g = c.g / 255.0; double b = c.b / 255.0; r = r <= 0.04045 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4).toDouble(); g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble(); b = b <= 0.04045 ? b / 12.92 : math.pow((b + 0.055) / 1.055, 2.4).toDouble(); final x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; final z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; double f(double t) => t > 0.008856 ? math.pow(t, 1.0 / 3.0).toDouble() : (7.787 * t) + (16 / 116); final fx = f(x); final fy = f(y); final fz = f(z); final l = (116 * fy) - 16; final a = 500 * (fx - fy); final bStar = 200 * (fy - fz); return [l, a, bStar]; } double _deltaE76(List lab1, List lab2) { final dl = lab1[0] - lab2[0]; final da = lab1[1] - lab2[1]; final db = lab1[2] - lab2[2]; return math.sqrt(dl * dl + da * da + db * db); }