Compare commits
2 Commits
apk-overfl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3651b073bf | ||
|
|
b553c29d39 |
343
lib/main.dart
343
lib/main.dart
@@ -62,7 +62,8 @@ class KorkenMosaicApp extends StatelessWidget {
|
|||||||
indicatorColor: const Color(0x804FD6E8),
|
indicatorColor: const Color(0x804FD6E8),
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.76),
|
backgroundColor: Colors.white.withValues(alpha: 0.76),
|
||||||
labelTextStyle: WidgetStatePropertyAll(
|
labelTextStyle: WidgetStatePropertyAll(
|
||||||
TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
TextStyle(
|
||||||
|
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
sliderTheme: SliderThemeData(
|
sliderTheme: SliderThemeData(
|
||||||
@@ -114,6 +115,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
||||||
bool _isCaptureFlowInProgress = false;
|
bool _isCaptureFlowInProgress = false;
|
||||||
bool _isRecoveringCapture = false;
|
bool _isRecoveringCapture = false;
|
||||||
|
bool _isProjectBusy = false;
|
||||||
|
|
||||||
double _fidelityStructure = 0.5;
|
double _fidelityStructure = 0.5;
|
||||||
double _ditheringStrength = 0.35;
|
double _ditheringStrength = 0.35;
|
||||||
@@ -133,6 +135,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
_loadCatalog();
|
_loadCatalog();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_recoverCaptureOnResumeOrStart();
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
_loadProject(silent: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +143,12 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_recoverCaptureOnResumeOrStart();
|
_recoverCaptureOnResumeOrStart();
|
||||||
|
_loadProject(silent: true);
|
||||||
|
}
|
||||||
|
if (state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_saveProject(silent: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +177,113 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
return file.path;
|
return file.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<File> _projectFile() async {
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
return File('${docs.path}/mosaic_project.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProject({bool silent = false}) async {
|
||||||
|
if (_isProjectBusy) return;
|
||||||
|
_isProjectBusy = true;
|
||||||
|
try {
|
||||||
|
final file = await _projectFile();
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'useCapSize': _useCapSize,
|
||||||
|
'gridWidth': _gridWidthCtrl.text,
|
||||||
|
'gridHeight': _gridHeightCtrl.text,
|
||||||
|
'capSize': _capSizeCtrl.text,
|
||||||
|
'fidelityStructure': _fidelityStructure,
|
||||||
|
'ditheringStrength': _ditheringStrength,
|
||||||
|
'edgeEmphasis': _edgeEmphasis,
|
||||||
|
'colorVariation': _colorVariation,
|
||||||
|
'selectedPreset': _selectedPreset.name,
|
||||||
|
'activeSection': _activeSection.name,
|
||||||
|
'sourceImageBase64':
|
||||||
|
_sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!),
|
||||||
|
};
|
||||||
|
await file.writeAsString(jsonEncode(payload), flush: true);
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Projekt gespeichert ✅')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Speichern fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProjectBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProject({bool silent = false}) async {
|
||||||
|
if (_isProjectBusy) return;
|
||||||
|
_isProjectBusy = true;
|
||||||
|
try {
|
||||||
|
final file = await _projectFile();
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
final data =
|
||||||
|
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final sourceB64 = data['sourceImageBase64'] as String?;
|
||||||
|
final source = sourceB64 == null ? null : base64Decode(sourceB64);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_useCapSize = data['useCapSize'] as bool? ?? _useCapSize;
|
||||||
|
_gridWidthCtrl.text =
|
||||||
|
data['gridWidth'] as String? ?? _gridWidthCtrl.text;
|
||||||
|
_gridHeightCtrl.text =
|
||||||
|
data['gridHeight'] as String? ?? _gridHeightCtrl.text;
|
||||||
|
_capSizeCtrl.text = data['capSize'] as String? ?? _capSizeCtrl.text;
|
||||||
|
_fidelityStructure = (data['fidelityStructure'] as num?)?.toDouble() ??
|
||||||
|
_fidelityStructure;
|
||||||
|
_ditheringStrength = (data['ditheringStrength'] as num?)?.toDouble() ??
|
||||||
|
_ditheringStrength;
|
||||||
|
_edgeEmphasis =
|
||||||
|
(data['edgeEmphasis'] as num?)?.toDouble() ?? _edgeEmphasis;
|
||||||
|
_colorVariation =
|
||||||
|
(data['colorVariation'] as num?)?.toDouble() ?? _colorVariation;
|
||||||
|
|
||||||
|
final presetName = data['selectedPreset'] as String?;
|
||||||
|
_selectedPreset = StylePreset.values.firstWhere(
|
||||||
|
(p) => p.name == presetName,
|
||||||
|
orElse: () => _selectedPreset,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sectionName = data['activeSection'] as String?;
|
||||||
|
_activeSection = HomeSection.values.firstWhere(
|
||||||
|
(s) => s.name == sectionName,
|
||||||
|
orElse: () => _activeSection,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (source != null) {
|
||||||
|
_sourceImageBytes = source;
|
||||||
|
_result = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source != null) {
|
||||||
|
await _generate();
|
||||||
|
}
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Projekt geladen ✅')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!silent && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Laden fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProjectBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCatalog() async {
|
Future<void> _loadCatalog() async {
|
||||||
final defaults = [
|
final defaults = [
|
||||||
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
||||||
@@ -211,6 +327,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
_sourceImageBytes = bytes;
|
_sourceImageBytes = bytes;
|
||||||
_result = null;
|
_result = null;
|
||||||
});
|
});
|
||||||
|
await _saveProject(silent: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _pendingCaptureFile() async {
|
Future<File> _pendingCaptureFile() async {
|
||||||
@@ -318,6 +435,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
detected = {
|
detected = {
|
||||||
'dominantColor': fallbackColor,
|
'dominantColor': fallbackColor,
|
||||||
'averageColor': fallbackColor,
|
'averageColor': fallbackColor,
|
||||||
|
'topDominantColors': [fallbackColor],
|
||||||
'usedFallback': true,
|
'usedFallback': true,
|
||||||
'circleX': imageW / 2,
|
'circleX': imageW / 2,
|
||||||
'circleY': imageH / 2,
|
'circleY': imageH / 2,
|
||||||
@@ -695,6 +813,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
);
|
);
|
||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
});
|
});
|
||||||
|
await _saveProject(silent: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -758,7 +877,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
_GlassCard(
|
_GlassCard(
|
||||||
child: Wrap(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
runSpacing: 10,
|
runSpacing: 10,
|
||||||
spacing: 10,
|
spacing: 10,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
@@ -768,10 +890,47 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
icon: const Icon(Icons.image_outlined),
|
icon: const Icon(Icons.image_outlined),
|
||||||
label: const Text('Import target image'),
|
label: const Text('Import target image'),
|
||||||
),
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _saveProject(),
|
||||||
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: const Text('Speichern'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _loadProject(),
|
||||||
|
icon: const Icon(Icons.folder_open_outlined),
|
||||||
|
label: const Text('Laden'),
|
||||||
|
),
|
||||||
if (_sourceImageBytes != null)
|
if (_sourceImageBytes != null)
|
||||||
const Chip(label: Text('Image loaded ✅')),
|
const Chip(label: Text('Image loaded ✅')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_sourceImageBytes != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 220,
|
||||||
|
width: double.infinity,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 1,
|
||||||
|
maxScale: 8,
|
||||||
|
panEnabled: true,
|
||||||
|
child: Image.memory(
|
||||||
|
_sourceImageBytes!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: double.infinity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Pinch zum Zoomen, mit einem Finger verschieben',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_GlassCard(
|
_GlassCard(
|
||||||
@@ -805,8 +964,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _gridHeightCtrl,
|
controller: _gridHeightCtrl,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration:
|
decoration: const InputDecoration(
|
||||||
const InputDecoration(labelText: 'Grid Height')),
|
labelText: 'Grid Height')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -825,13 +984,20 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Style Preset', style: Theme.of(context).textTheme.titleMedium),
|
Text('Style Preset',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SegmentedButton<StylePreset>(
|
SegmentedButton<StylePreset>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(value: StylePreset.realistisch, label: Text('Realistisch')),
|
ButtonSegment(
|
||||||
ButtonSegment(value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
|
value: StylePreset.realistisch,
|
||||||
ButtonSegment(value: StylePreset.kuenstlerisch, label: Text('Künstlerisch')),
|
label: Text('Realistisch')),
|
||||||
|
ButtonSegment(
|
||||||
|
value: StylePreset.ausgewogen,
|
||||||
|
label: Text('Ausgewogen')),
|
||||||
|
ButtonSegment(
|
||||||
|
value: StylePreset.kuenstlerisch,
|
||||||
|
label: Text('Künstlerisch')),
|
||||||
],
|
],
|
||||||
selected: {_selectedPreset},
|
selected: {_selectedPreset},
|
||||||
onSelectionChanged: (s) => _applyPreset(s.first),
|
onSelectionChanged: (s) => _applyPreset(s.first),
|
||||||
@@ -882,7 +1048,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Preview (${_result!.width} x ${_result!.height})', style: Theme.of(context).textTheme.titleMedium),
|
Text('Preview (${_result!.width} x ${_result!.height})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
@@ -904,7 +1071,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Bill of Materials', style: Theme.of(context).textTheme.titleMedium),
|
Text('Bill of Materials',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
..._result!.sortedCounts.map(
|
..._result!.sortedCounts.map(
|
||||||
(e) => ListTile(
|
(e) => ListTile(
|
||||||
@@ -1053,8 +1221,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints:
|
constraints: const BoxConstraints.tightFor(
|
||||||
const BoxConstraints.tightFor(width: 30, height: 30),
|
width: 30, height: 30),
|
||||||
onPressed: _catalog.length <= 1
|
onPressed: _catalog.length <= 1
|
||||||
? null
|
? null
|
||||||
: () => _deleteEntry(entry),
|
: () => _deleteEntry(entry),
|
||||||
@@ -1123,6 +1291,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
|
|
||||||
late Color _dominantColor;
|
late Color _dominantColor;
|
||||||
late Color _averageColor;
|
late Color _averageColor;
|
||||||
|
late List<Color> _topDominantColors;
|
||||||
late Uint8List _previewBytes;
|
late Uint8List _previewBytes;
|
||||||
late bool _usedFallback;
|
late bool _usedFallback;
|
||||||
late double _imageW;
|
late double _imageW;
|
||||||
@@ -1143,6 +1312,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
final detected = widget.detected;
|
final detected = widget.detected;
|
||||||
_dominantColor = Color(detected['dominantColor'] as int);
|
_dominantColor = Color(detected['dominantColor'] as int);
|
||||||
_averageColor = Color(detected['averageColor'] as int);
|
_averageColor = Color(detected['averageColor'] as int);
|
||||||
|
_topDominantColors = ((detected['topDominantColors'] as List?) ??
|
||||||
|
[detected['dominantColor']])
|
||||||
|
.map((e) => Color(e as int))
|
||||||
|
.toList(growable: false);
|
||||||
_previewBytes = detected['previewPng'] as Uint8List;
|
_previewBytes = detected['previewPng'] as Uint8List;
|
||||||
_usedFallback = detected['usedFallback'] as bool? ?? false;
|
_usedFallback = detected['usedFallback'] as bool? ?? false;
|
||||||
_imageW = (detected['imageW'] as num).toDouble();
|
_imageW = (detected['imageW'] as num).toDouble();
|
||||||
@@ -1218,6 +1391,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
if (localToken != _recalcToken) return;
|
if (localToken != _recalcToken) return;
|
||||||
_dominantColor = Color(adjusted['dominantColor'] as int);
|
_dominantColor = Color(adjusted['dominantColor'] as int);
|
||||||
_averageColor = Color(adjusted['averageColor'] as int);
|
_averageColor = Color(adjusted['averageColor'] as int);
|
||||||
|
_topDominantColors = ((adjusted['topDominantColors'] as List?) ??
|
||||||
|
[adjusted['dominantColor']])
|
||||||
|
.map((e) => Color(e as int))
|
||||||
|
.toList(growable: false);
|
||||||
_previewBytes = adjusted['previewPng'] as Uint8List;
|
_previewBytes = adjusted['previewPng'] as Uint8List;
|
||||||
_selected = _mode == ColorExtractionMode.dominant
|
_selected = _mode == ColorExtractionMode.dominant
|
||||||
? _dominantColor
|
? _dominantColor
|
||||||
@@ -1231,6 +1408,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
const fallback = Colors.orange;
|
const fallback = Colors.orange;
|
||||||
_dominantColor = fallback;
|
_dominantColor = fallback;
|
||||||
_averageColor = fallback;
|
_averageColor = fallback;
|
||||||
|
_topDominantColors = [fallback];
|
||||||
_selected = fallback;
|
_selected = fallback;
|
||||||
_hexCtrl.text = _colorToHexStatic(_selected);
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
}
|
}
|
||||||
@@ -1311,6 +1489,39 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Dominante Farben (Anteil) + Durchschnitt:',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _topDominantColors.length; i++)
|
||||||
|
_ColorPickChip(
|
||||||
|
label: 'D${i + 1}',
|
||||||
|
color: _topDominantColors[i],
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selected = _topDominantColors[i];
|
||||||
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ColorPickChip(
|
||||||
|
label: 'Mix',
|
||||||
|
color: _averageColor,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selected = _averageColor;
|
||||||
|
_hexCtrl.text = _colorToHexStatic(_selected);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: _circleR,
|
value: _circleR,
|
||||||
min: 0.08,
|
min: 0.08,
|
||||||
@@ -1401,6 +1612,50 @@ String _colorToHexStatic(Color color) {
|
|||||||
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ColorPickChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ColorPickChip({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.black12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: Colors.black26),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AeroBackgroundAccents extends StatelessWidget {
|
class _AeroBackgroundAccents extends StatelessWidget {
|
||||||
const _AeroBackgroundAccents();
|
const _AeroBackgroundAccents();
|
||||||
|
|
||||||
@@ -1469,7 +1724,8 @@ class _GlassCard extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final EdgeInsetsGeometry padding;
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
const _GlassCard({required this.child, this.padding = const EdgeInsets.all(12)});
|
const _GlassCard(
|
||||||
|
{required this.child, this.padding = const EdgeInsets.all(12)});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2018,9 +2274,11 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
|
|||||||
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
||||||
final decoded = img.decodeImage(sourceBytes);
|
final decoded = img.decodeImage(sourceBytes);
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
|
final fallback = Colors.orange.toARGB32();
|
||||||
return {
|
return {
|
||||||
'dominantColor': Colors.orange.toARGB32(),
|
'dominantColor': fallback,
|
||||||
'averageColor': Colors.orange.toARGB32(),
|
'averageColor': fallback,
|
||||||
|
'topDominantColors': [fallback],
|
||||||
'usedFallback': true,
|
'usedFallback': true,
|
||||||
'circleX': 0.5,
|
'circleX': 0.5,
|
||||||
'circleY': 0.5,
|
'circleY': 0.5,
|
||||||
@@ -2065,6 +2323,7 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
|||||||
return {
|
return {
|
||||||
'dominantColor': stats.dominantArgb,
|
'dominantColor': stats.dominantArgb,
|
||||||
'averageColor': stats.averageArgb,
|
'averageColor': stats.averageArgb,
|
||||||
|
'topDominantColors': stats.topDominantArgbs,
|
||||||
'usedFallback': usedFallback,
|
'usedFallback': usedFallback,
|
||||||
'circleX': upscaledCircle.cx,
|
'circleX': upscaledCircle.cx,
|
||||||
'circleY': upscaledCircle.cy,
|
'circleY': upscaledCircle.cy,
|
||||||
@@ -2080,9 +2339,11 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|||||||
final sourceBytes = request['sourceBytes'] as Uint8List;
|
final sourceBytes = request['sourceBytes'] as Uint8List;
|
||||||
final decoded = img.decodeImage(sourceBytes);
|
final decoded = img.decodeImage(sourceBytes);
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
|
final fallback = Colors.orange.toARGB32();
|
||||||
return {
|
return {
|
||||||
'dominantColor': Colors.orange.toARGB32(),
|
'dominantColor': fallback,
|
||||||
'averageColor': Colors.orange.toARGB32(),
|
'averageColor': fallback,
|
||||||
|
'topDominantColors': [fallback],
|
||||||
'previewPng': Uint8List.fromList(
|
'previewPng': Uint8List.fromList(
|
||||||
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
||||||
};
|
};
|
||||||
@@ -2128,6 +2389,7 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|||||||
return {
|
return {
|
||||||
'dominantColor': stats.dominantArgb,
|
'dominantColor': stats.dominantArgb,
|
||||||
'averageColor': stats.averageArgb,
|
'averageColor': stats.averageArgb,
|
||||||
|
'topDominantColors': stats.topDominantArgbs,
|
||||||
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2277,7 +2539,11 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
|||||||
|
|
||||||
if (samples.isEmpty) {
|
if (samples.isEmpty) {
|
||||||
final fallback = const Color(0xFFFF9800).toARGB32();
|
final fallback = const Color(0xFFFF9800).toARGB32();
|
||||||
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
|
return _CapColorStats(
|
||||||
|
averageArgb: fallback,
|
||||||
|
dominantArgb: fallback,
|
||||||
|
topDominantArgbs: [fallback],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final lumValues = samples.map((s) => s.luminance).toList()..sort();
|
final lumValues = samples.map((s) => s.luminance).toList()..sort();
|
||||||
@@ -2293,9 +2559,14 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
|||||||
final usable = filtered.isEmpty ? samples : filtered;
|
final usable = filtered.isEmpty ? samples : filtered;
|
||||||
|
|
||||||
final avg = _weightedAverage(usable);
|
final avg = _weightedAverage(usable);
|
||||||
final dominant = _weightedDominant(usable);
|
final topDominant = _weightedDominantTop(usable, 3);
|
||||||
|
final dominant = topDominant.first;
|
||||||
|
|
||||||
return _CapColorStats(averageArgb: avg, dominantArgb: dominant);
|
return _CapColorStats(
|
||||||
|
averageArgb: avg,
|
||||||
|
dominantArgb: dominant,
|
||||||
|
topDominantArgbs: topDominant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawCircle(img.Image image, double cx, double cy, double r) {
|
void _drawCircle(img.Image image, double cx, double cy, double r) {
|
||||||
@@ -2331,8 +2602,13 @@ class _DetectedCircle {
|
|||||||
class _CapColorStats {
|
class _CapColorStats {
|
||||||
final int averageArgb;
|
final int averageArgb;
|
||||||
final int dominantArgb;
|
final int dominantArgb;
|
||||||
|
final List<int> topDominantArgbs;
|
||||||
|
|
||||||
const _CapColorStats({required this.averageArgb, required this.dominantArgb});
|
const _CapColorStats({
|
||||||
|
required this.averageArgb,
|
||||||
|
required this.dominantArgb,
|
||||||
|
required this.topDominantArgbs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ColorSample {
|
class _ColorSample {
|
||||||
@@ -2393,7 +2669,7 @@ int _weightedAverage(List<_ColorSample> samples) {
|
|||||||
).toARGB32();
|
).toARGB32();
|
||||||
}
|
}
|
||||||
|
|
||||||
int _weightedDominant(List<_ColorSample> samples) {
|
List<int> _weightedDominantTop(List<_ColorSample> samples, int count) {
|
||||||
final bins = <int, _WeightedRgb>{};
|
final bins = <int, _WeightedRgb>{};
|
||||||
for (final s in samples) {
|
for (final s in samples) {
|
||||||
final hsv = _rgbToHsv(s.r, s.g, s.b);
|
final hsv = _rgbToHsv(s.r, s.g, s.b);
|
||||||
@@ -2405,18 +2681,25 @@ int _weightedDominant(List<_ColorSample> samples) {
|
|||||||
bucket.add(s, s.weight);
|
bucket.add(s, s.weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
_WeightedRgb? best;
|
if (bins.isEmpty) return [_weightedAverage(samples)];
|
||||||
for (final b in bins.values) {
|
|
||||||
if (best == null || b.weight > best.weight) best = b;
|
|
||||||
}
|
|
||||||
if (best == null || best.weight <= 0) return _weightedAverage(samples);
|
|
||||||
|
|
||||||
|
final sorted = bins.values.toList()
|
||||||
|
..sort((a, b) => b.weight.compareTo(a.weight));
|
||||||
|
|
||||||
|
final take = math.max(1, math.min(count, sorted.length));
|
||||||
|
return sorted.take(take).map((b) {
|
||||||
|
if (b.weight <= 0) return _weightedAverage(samples);
|
||||||
return Color.fromARGB(
|
return Color.fromARGB(
|
||||||
255,
|
255,
|
||||||
(best.r / best.weight).round().clamp(0, 255),
|
(b.r / b.weight).round().clamp(0, 255),
|
||||||
(best.g / best.weight).round().clamp(0, 255),
|
(b.g / b.weight).round().clamp(0, 255),
|
||||||
(best.b / best.weight).round().clamp(0, 255),
|
(b.b / b.weight).round().clamp(0, 255),
|
||||||
).toARGB32();
|
).toARGB32();
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _weightedDominant(List<_ColorSample> samples) {
|
||||||
|
return _weightedDominantTop(samples, 1).first;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> _rgbToHsv(int r, int g, int b) {
|
List<double> _rgbToHsv(int r, int g, int b) {
|
||||||
|
|||||||
Reference in New Issue
Block a user