commit d65875ba84ca1b3ce78d14a02dd2bf7563ba3e97 Author: gary Date: Sat Feb 21 18:24:42 2026 +0100 Initial Flutter MVP for bottle-cap mosaic generator diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aeda00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.dart_tool/ +.packages +.pub/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +.idea/ +*.iml +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c504376 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Korken Mosaic (Flutter MVP) + +Prototype Flutter app for generating bottle-cap mosaics from imported images. + +## Implemented MVP + +- Import target image from gallery (`image_picker`) +- 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 + - remove caps +- Mosaic generation: + - resize source to grid + - nearest cap color match using CIELAB + DeltaE (CIE76) + - fallback concept is RGB distance, but LAB path is implemented directly +- Output: + - mosaic grid preview + - bill of materials counts per cap color + +## Current blocker on this machine + +`flutter` SDK is not installed (`flutter: command not found`), so I could not run: + +- `flutter create` +- `flutter pub get` +- `flutter analyze` +- `flutter build apk --debug` + +## Setup commands (Ubuntu/Debian) + +```bash +cd /home/yadciel/.openclaw/workspace +sudo snap install flutter --classic +# OR: install manually from flutter.dev and add to PATH + +flutter doctor + +cd /home/yadciel/.openclaw/workspace/korken_mosaic +flutter create . +flutter pub get +flutter run +flutter build apk --debug +``` + +Expected APK artifact: + +`build/app/outputs/flutter-apk/app-debug.apk` + +## Project files + +- `lib/main.dart` – complete MVP UI + mosaic logic +- `pubspec.yaml` – dependencies +- `analysis_options.yaml` +- `.gitignore` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..ca6f4a1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,369 @@ +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); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..e50876b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,23 @@ +name: korken_mosaic +description: Prototype app to create bottle-cap mosaics from photos. +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ">=3.2.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + image_picker: ^1.1.2 + image: ^4.2.0 + flutter_colorpicker: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true