4 Commits

View File

@@ -19,9 +19,60 @@ class KorkenMosaicApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
const seed = Color(0xFF26BFD6);
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
return MaterialApp(
title: 'Korken Mosaic',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFFEAF9FF),
textTheme: ThemeData.light().textTheme.copyWith(
titleLarge: const TextStyle(fontWeight: FontWeight.w700),
titleMedium: const TextStyle(fontWeight: FontWeight.w600),
bodyLarge: const TextStyle(height: 1.3),
),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white.withValues(alpha: 0.52),
shadowColor: const Color(0x3326BFD6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(color: Colors.white.withValues(alpha: 0.72)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white.withValues(alpha: 0.64),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.75)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.7)),
),
),
navigationBarTheme: NavigationBarThemeData(
indicatorColor: const Color(0x804FD6E8),
backgroundColor: Colors.white.withValues(alpha: 0.76),
labelTextStyle: WidgetStatePropertyAll(
TextStyle(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
),
),
sliderTheme: SliderThemeData(
activeTrackColor: const Color(0xFF26BFD6),
inactiveTrackColor: const Color(0x5526BFD6),
thumbColor: Colors.white,
overlayColor: const Color(0x4026BFD6),
),
),
home: const MosaicHomePage(),
);
}
@@ -64,6 +115,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
bool _isCaptureFlowInProgress = false;
bool _isRecoveringCapture = false;
bool _isProjectBusy = false;
double _fidelityStructure = 0.5;
double _ditheringStrength = 0.35;
@@ -83,6 +135,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_loadCatalog();
WidgetsBinding.instance.addPostFrameCallback((_) {
_recoverCaptureOnResumeOrStart();
_loadProject(silent: true);
});
}
@@ -90,6 +143,12 @@ class _MosaicHomePageState extends State<MosaicHomePage>
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_recoverCaptureOnResumeOrStart();
_loadProject(silent: true);
}
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_saveProject(silent: true);
}
}
@@ -118,6 +177,113 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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 {
final defaults = [
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
@@ -161,6 +327,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_sourceImageBytes = bytes;
_result = null;
});
await _saveProject(silent: true);
}
Future<File> _pendingCaptureFile() async {
@@ -268,6 +435,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
detected = {
'dominantColor': fallbackColor,
'averageColor': fallbackColor,
'topDominantColors': [fallbackColor],
'usedFallback': true,
'circleX': imageW / 2,
'circleY': imageH / 2,
@@ -645,33 +813,43 @@ class _MosaicHomePageState extends State<MosaicHomePage>
);
_isGenerating = false;
});
await _saveProject(silent: true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBody: true,
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.45),
title: Text(_activeSection == HomeSection.mosaic
? 'Bottle-Cap Mosaic Prototype'
? 'Bottle-Cap Mosaic Studio'
: 'Cap Catalog'),
),
floatingActionButton: _activeSection == HomeSection.mosaic
? FloatingActionButton.extended(
backgroundColor: Colors.white.withValues(alpha: 0.85),
foregroundColor: Theme.of(context).colorScheme.primary,
onPressed: _isGenerating ? null : _generate,
icon: _isGenerating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_fix_high),
: const Icon(Icons.auto_fix_high_rounded),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
)
: null,
body: !_isCatalogLoaded
body: Stack(
children: [
const _AeroBackgroundAccents(),
!_isCatalogLoaded
? const Center(child: CircularProgressIndicator())
: _activeSection == HomeSection.mosaic
? _buildMosaicScreen()
: _buildCatalogScreen(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _activeSection.index,
onDestinationSelected: (index) {
@@ -695,12 +873,16 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Widget _buildMosaicScreen() {
return Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(14),
child: ListView(
children: [
_GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
runSpacing: 8,
spacing: 8,
runSpacing: 10,
spacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FilledButton.icon(
@@ -708,10 +890,53 @@ class _MosaicHomePageState extends State<MosaicHomePage>
icon: const Icon(Icons.image_outlined),
label: const Text('Import target image'),
),
if (_sourceImageBytes != null) const Text('Image loaded ✅'),
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)
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),
_GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Grid W x H')),
@@ -723,7 +948,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_scheduleRegenerate();
},
),
const SizedBox(height: 8),
const SizedBox(height: 10),
if (!_useCapSize)
Row(
children: [
@@ -734,13 +959,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
decoration:
const InputDecoration(labelText: 'Grid Width')),
),
const SizedBox(width: 8),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: _gridHeightCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Grid Height')),
decoration: const InputDecoration(
labelText: 'Grid Height')),
),
],
)
@@ -751,16 +976,25 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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: 12),
_GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Style Preset',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SegmentedButton<StylePreset>(
segments: const [
ButtonSegment(
value: StylePreset.realistisch, label: Text('Realistisch')),
value: StylePreset.realistisch,
label: Text('Realistisch')),
ButtonSegment(
value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
value: StylePreset.ausgewogen,
label: Text('Ausgewogen')),
ButtonSegment(
value: StylePreset.kuenstlerisch,
label: Text('Künstlerisch')),
@@ -769,11 +1003,6 @@ class _MosaicHomePageState extends State<MosaicHomePage>
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',
@@ -813,14 +1042,18 @@ class _MosaicHomePageState extends State<MosaicHomePage>
],
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
if (_result != null) ...[
_GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Preview (${_result!.width} x ${_result!.height})',
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
RepaintBoundary(
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: _result!.width / _result!.height,
child: Image.memory(_result!.previewPng,
@@ -829,9 +1062,18 @@ class _MosaicHomePageState extends State<MosaicHomePage>
gaplessPlayback: true),
),
),
const SizedBox(height: 16),
const Text('Bill of Materials',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
],
),
),
const SizedBox(height: 12),
_GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Bill of Materials',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
..._result!.sortedCounts.map(
(e) => ListTile(
dense: true,
@@ -841,6 +1083,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
),
),
],
),
),
],
],
),
);
@@ -848,15 +1093,16 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Widget _buildCatalogScreen() {
return Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(14),
child: ListView(
children: [
Row(
_GlassCard(
child: Row(
children: [
IconButton(
onPressed: () =>
setState(() => _catalogViewMode = CatalogViewMode.list),
icon: Icon(Icons.view_list,
icon: Icon(Icons.view_list_rounded,
color: _catalogViewMode == CatalogViewMode.list
? Theme.of(context).colorScheme.primary
: null),
@@ -865,7 +1111,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
IconButton(
onPressed: () =>
setState(() => _catalogViewMode = CatalogViewMode.grid),
icon: Icon(Icons.grid_view,
icon: Icon(Icons.grid_view_rounded,
color: _catalogViewMode == CatalogViewMode.grid
? Theme.of(context).colorScheme.primary
: null),
@@ -873,7 +1119,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
),
const Spacer(),
OutlinedButton.icon(
onPressed: _isCaptureFlowInProgress ? null : _captureCapPhoto,
onPressed:
_isCaptureFlowInProgress ? null : _captureCapPhoto,
icon: _isCaptureFlowInProgress
? const SizedBox(
width: 16,
@@ -885,11 +1132,12 @@ class _MosaicHomePageState extends State<MosaicHomePage>
const SizedBox(width: 8),
IconButton(
onPressed: _addCapDialog,
icon: const Icon(Icons.add_circle_outline),
icon: const Icon(Icons.add_circle_outline_rounded),
tooltip: 'Manuell hinzufügen'),
],
),
const SizedBox(height: 8),
),
const SizedBox(height: 10),
_buildCatalogView(),
],
),
@@ -903,7 +1151,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
return Column(
children: _catalog
.map(
(entry) => Card(
(entry) => _GlassCard(
child: ListTile(
onTap: () => _editEntry(entry),
leading: SizedBox(
@@ -936,15 +1184,17 @@ class _MosaicHomePageState extends State<MosaicHomePage>
itemCount: _catalog.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.2,
// Mehr vertikaler Platz pro Card, damit Name/Hex/Delete nicht überlaufen.
childAspectRatio: 0.92,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemBuilder: (context, index) {
final entry = _catalog[index];
return Card(
clipBehavior: Clip.antiAlias,
return _GlassCard(
padding: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => _editEntry(entry),
child: Padding(
padding: const EdgeInsets.all(8),
@@ -970,10 +1220,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 30, height: 30),
onPressed: _catalog.length <= 1
? null
: () => _deleteEntry(entry),
icon: const Icon(Icons.delete_outline),
icon: const Icon(Icons.delete_outline, size: 20),
),
],
),
@@ -1038,6 +1291,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
late Color _dominantColor;
late Color _averageColor;
late List<Color> _topDominantColors;
late Uint8List _previewBytes;
late bool _usedFallback;
late double _imageW;
@@ -1058,6 +1312,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
final detected = widget.detected;
_dominantColor = Color(detected['dominantColor'] 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;
_usedFallback = detected['usedFallback'] as bool? ?? false;
_imageW = (detected['imageW'] as num).toDouble();
@@ -1133,6 +1391,10 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
if (localToken != _recalcToken) return;
_dominantColor = Color(adjusted['dominantColor'] 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;
_selected = _mode == ColorExtractionMode.dominant
? _dominantColor
@@ -1146,6 +1408,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
const fallback = Colors.orange;
_dominantColor = fallback;
_averageColor = fallback;
_topDominantColors = [fallback];
_selected = fallback;
_hexCtrl.text = _colorToHexStatic(_selected);
}
@@ -1163,12 +1426,16 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Deckel prüfen')),
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.45),
title: const Text('Deckel prüfen'),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(14),
children: [
_CircleAdjustOverlay(
_GlassCard(
child: _CircleAdjustOverlay(
imageBytes: widget.imageBytes,
imageWidth: _imageW,
imageHeight: _imageH,
@@ -1182,6 +1449,7 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
_scheduleLiveRecalculate();
},
),
),
const SizedBox(height: 8),
Text(
_usedFallback
@@ -1221,6 +1489,39 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
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(
value: _circleR,
min: 0.08,
@@ -1311,6 +1612,149 @@ String _colorToHexStatic(Color color) {
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 {
const _AeroBackgroundAccents();
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFEFFFFF), Color(0xFFE9F8FF), Color(0xFFE8FFF3)],
),
),
child: Stack(
children: [
Positioned(
left: -80,
top: -90,
child: _AccentBlob(size: 260, color: Color(0x5524D8FF)),
),
Positioned(
right: -60,
top: 120,
child: _AccentBlob(size: 200, color: Color(0x4452E3FF)),
),
Positioned(
left: 40,
bottom: -70,
child: _AccentBlob(size: 220, color: Color(0x4448E6AA)),
),
],
),
),
);
}
}
class _AccentBlob extends StatelessWidget {
final double size;
final Color color;
const _AccentBlob({required this.size, required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(size),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 36,
spreadRadius: 2,
),
],
),
);
}
}
class _GlassCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
const _GlassCard(
{required this.child, this.padding = const EdgeInsets.all(12)});
@override
Widget build(BuildContext context) {
return Card(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.65),
Colors.white.withValues(alpha: 0.34),
],
),
boxShadow: const [
BoxShadow(
color: Color(0x2226BFD6),
blurRadius: 16,
offset: Offset(0, 8),
),
],
),
child: Padding(padding: padding, child: child),
),
);
}
}
class _CapThumb extends StatelessWidget {
final CapCatalogEntry entry;
final bool large;
@@ -1830,9 +2274,11 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
final decoded = img.decodeImage(sourceBytes);
if (decoded == null) {
final fallback = Colors.orange.toARGB32();
return {
'dominantColor': Colors.orange.toARGB32(),
'averageColor': Colors.orange.toARGB32(),
'dominantColor': fallback,
'averageColor': fallback,
'topDominantColors': [fallback],
'usedFallback': true,
'circleX': 0.5,
'circleY': 0.5,
@@ -1877,6 +2323,7 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
return {
'dominantColor': stats.dominantArgb,
'averageColor': stats.averageArgb,
'topDominantColors': stats.topDominantArgbs,
'usedFallback': usedFallback,
'circleX': upscaledCircle.cx,
'circleY': upscaledCircle.cy,
@@ -1892,9 +2339,11 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
final sourceBytes = request['sourceBytes'] as Uint8List;
final decoded = img.decodeImage(sourceBytes);
if (decoded == null) {
final fallback = Colors.orange.toARGB32();
return {
'dominantColor': Colors.orange.toARGB32(),
'averageColor': Colors.orange.toARGB32(),
'dominantColor': fallback,
'averageColor': fallback,
'topDominantColors': [fallback],
'previewPng': Uint8List.fromList(
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
};
@@ -1940,6 +2389,7 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
return {
'dominantColor': stats.dominantArgb,
'averageColor': stats.averageArgb,
'topDominantColors': stats.topDominantArgbs,
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
};
}
@@ -2089,7 +2539,11 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
if (samples.isEmpty) {
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();
@@ -2105,9 +2559,14 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
final usable = filtered.isEmpty ? samples : filtered;
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) {
@@ -2143,8 +2602,13 @@ class _DetectedCircle {
class _CapColorStats {
final int averageArgb;
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 {
@@ -2205,7 +2669,7 @@ int _weightedAverage(List<_ColorSample> samples) {
).toARGB32();
}
int _weightedDominant(List<_ColorSample> samples) {
List<int> _weightedDominantTop(List<_ColorSample> samples, int count) {
final bins = <int, _WeightedRgb>{};
for (final s in samples) {
final hsv = _rgbToHsv(s.r, s.g, s.b);
@@ -2217,18 +2681,25 @@ int _weightedDominant(List<_ColorSample> samples) {
bucket.add(s, s.weight);
}
_WeightedRgb? best;
for (final b in bins.values) {
if (best == null || b.weight > best.weight) best = b;
}
if (best == null || best.weight <= 0) return _weightedAverage(samples);
if (bins.isEmpty) 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(
255,
(best.r / best.weight).round().clamp(0, 255),
(best.g / best.weight).round().clamp(0, 255),
(best.b / best.weight).round().clamp(0, 255),
(b.r / b.weight).round().clamp(0, 255),
(b.g / b.weight).round().clamp(0, 255),
(b.b / b.weight).round().clamp(0, 255),
).toARGB32();
}).toList(growable: false);
}
int _weightedDominant(List<_ColorSample> samples) {
return _weightedDominantTop(samples, 1).first;
}
List<double> _rgbToHsv(int r, int g, int b) {