2 Commits

Author SHA1 Message Date
gary
84d649ac6d fix(catalog): prevent grid card overflow on mobile 2026-02-22 12:22:34 +01:00
gary
75a3a8dd60 feat(ui): frutiger aero redesign for mosaic and catalog flows 2026-02-22 03:34:07 +01:00

View File

@@ -19,9 +19,59 @@ class KorkenMosaicApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const seed = Color(0xFF26BFD6);
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
return MaterialApp( return MaterialApp(
title: 'Korken Mosaic', 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(), home: const MosaicHomePage(),
); );
} }
@@ -650,28 +700,37 @@ class _MosaicHomePageState extends State<MosaicHomePage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.45),
title: Text(_activeSection == HomeSection.mosaic title: Text(_activeSection == HomeSection.mosaic
? 'Bottle-Cap Mosaic Prototype' ? 'Bottle-Cap Mosaic Studio'
: 'Cap Catalog'), : 'Cap Catalog'),
), ),
floatingActionButton: _activeSection == HomeSection.mosaic floatingActionButton: _activeSection == HomeSection.mosaic
? FloatingActionButton.extended( ? FloatingActionButton.extended(
backgroundColor: Colors.white.withValues(alpha: 0.85),
foregroundColor: Theme.of(context).colorScheme.primary,
onPressed: _isGenerating ? null : _generate, onPressed: _isGenerating ? null : _generate,
icon: _isGenerating icon: _isGenerating
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2)) child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_fix_high), : const Icon(Icons.auto_fix_high_rounded),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'), label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
) )
: null, : null,
body: !_isCatalogLoaded body: Stack(
? const Center(child: CircularProgressIndicator()) children: [
: _activeSection == HomeSection.mosaic const _AeroBackgroundAccents(),
? _buildMosaicScreen() !_isCatalogLoaded
: _buildCatalogScreen(), ? const Center(child: CircularProgressIndicator())
: _activeSection == HomeSection.mosaic
? _buildMosaicScreen()
: _buildCatalogScreen(),
],
),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _activeSection.index, selectedIndex: _activeSection.index,
onDestinationSelected: (index) { onDestinationSelected: (index) {
@@ -695,149 +754,167 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Widget _buildMosaicScreen() { Widget _buildMosaicScreen() {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(14),
child: ListView( child: ListView(
children: [ children: [
Wrap( _GlassCard(
runSpacing: 8, child: Wrap(
spacing: 8, runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center, spacing: 10,
children: [ crossAxisAlignment: WrapCrossAlignment.center,
FilledButton.icon( children: [
onPressed: _pickImage, FilledButton.icon(
icon: const Icon(Icons.image_outlined), onPressed: _pickImage,
label: const Text('Import target image'), icon: const Icon(Icons.image_outlined),
), label: const Text('Import target image'),
if (_sourceImageBytes != null) const Text('Image loaded ✅'), ),
], if (_sourceImageBytes != null)
const Chip(label: Text('Image loaded ✅')),
],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SegmentedButton<bool>( _GlassCard(
segments: const [ child: Column(
ButtonSegment(value: false, label: Text('Grid W x H')), crossAxisAlignment: CrossAxisAlignment.start,
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: [ children: [
Expanded( SegmentedButton<bool>(
child: TextField( segments: const [
controller: _gridWidthCtrl, ButtonSegment(value: false, label: Text('Grid W x H')),
keyboardType: TextInputType.number, ButtonSegment(value: true, label: Text('Cap size (px)')),
decoration: ],
const InputDecoration(labelText: 'Grid Width')), selected: {_useCapSize},
), onSelectionChanged: (s) {
const SizedBox(width: 8), setState(() => _useCapSize = s.first);
Expanded( _scheduleRegenerate();
child: TextField( },
controller: _gridHeightCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Grid Height')),
), ),
const SizedBox(height: 10),
if (!_useCapSize)
Row(
children: [
Expanded(
child: TextField(
controller: _gridWidthCtrl,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(labelText: 'Grid Width')),
),
const SizedBox(width: 10),
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)'),
),
], ],
)
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<StylePreset>(
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), const SizedBox(height: 12),
Card( _GlassCard(
child: Padding( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 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')),
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),
_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(height: 14),
if (_result != null) ...[
_GlassCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SliderRow( Text('Preview (${_result!.width} x ${_result!.height})', style: Theme.of(context).textTheme.titleMedium),
label: 'Fidelity ↔ Structure', const SizedBox(height: 8),
leftLabel: 'Fidelity', ClipRRect(
rightLabel: 'Structure', borderRadius: BorderRadius.circular(18),
value: _fidelityStructure, child: RepaintBoundary(
onChanged: (v) { child: AspectRatio(
setState(() => _fidelityStructure = v); aspectRatio: _result!.width / _result!.height,
_onStyleChanged(); child: Image.memory(_result!.previewPng,
}), fit: BoxFit.fill,
_SliderRow( filterQuality: FilterQuality.none,
label: 'Dithering strength', gaplessPlayback: true),
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: 12),
const SizedBox(height: 16), _GlassCard(
if (_result != null) ...[ child: Column(
Text('Preview (${_result!.width} x ${_result!.height})', crossAxisAlignment: CrossAxisAlignment.start,
style: children: [
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text('Bill of Materials', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 6),
RepaintBoundary( ..._result!.sortedCounts.map(
child: AspectRatio( (e) => ListTile(
aspectRatio: _result!.width / _result!.height, dense: true,
child: Image.memory(_result!.previewPng, leading: CircleAvatar(backgroundColor: e.key.color),
fit: BoxFit.fill, title: Text(e.key.name),
filterQuality: FilterQuality.none, trailing: Text('${e.value} caps'),
gaplessPlayback: true), ),
), ),
), ],
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'),
), ),
), ),
], ],
@@ -848,48 +925,51 @@ class _MosaicHomePageState extends State<MosaicHomePage>
Widget _buildCatalogScreen() { Widget _buildCatalogScreen() {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(14),
child: ListView( child: ListView(
children: [ children: [
Row( _GlassCard(
children: [ child: Row(
IconButton( children: [
onPressed: () => IconButton(
setState(() => _catalogViewMode = CatalogViewMode.list), onPressed: () =>
icon: Icon(Icons.view_list, setState(() => _catalogViewMode = CatalogViewMode.list),
color: _catalogViewMode == CatalogViewMode.list icon: Icon(Icons.view_list_rounded,
? Theme.of(context).colorScheme.primary color: _catalogViewMode == CatalogViewMode.list
: null), ? Theme.of(context).colorScheme.primary
tooltip: 'Listenansicht', : null),
), tooltip: 'Listenansicht',
IconButton( ),
onPressed: () => IconButton(
setState(() => _catalogViewMode = CatalogViewMode.grid), onPressed: () =>
icon: Icon(Icons.grid_view, setState(() => _catalogViewMode = CatalogViewMode.grid),
color: _catalogViewMode == CatalogViewMode.grid icon: Icon(Icons.grid_view_rounded,
? Theme.of(context).colorScheme.primary color: _catalogViewMode == CatalogViewMode.grid
: null), ? Theme.of(context).colorScheme.primary
tooltip: 'Rasteransicht', : null),
), tooltip: 'Rasteransicht',
const Spacer(), ),
OutlinedButton.icon( const Spacer(),
onPressed: _isCaptureFlowInProgress ? null : _captureCapPhoto, OutlinedButton.icon(
icon: _isCaptureFlowInProgress onPressed:
? const SizedBox( _isCaptureFlowInProgress ? null : _captureCapPhoto,
width: 16, icon: _isCaptureFlowInProgress
height: 16, ? const SizedBox(
child: CircularProgressIndicator(strokeWidth: 2), width: 16,
) height: 16,
: const Icon(Icons.photo_camera_outlined), child: CircularProgressIndicator(strokeWidth: 2),
label: Text(_isCaptureFlowInProgress ? 'Läuft…' : 'Foto')), )
const SizedBox(width: 8), : const Icon(Icons.photo_camera_outlined),
IconButton( label: Text(_isCaptureFlowInProgress ? 'Läuft…' : 'Foto')),
onPressed: _addCapDialog, const SizedBox(width: 8),
icon: const Icon(Icons.add_circle_outline), IconButton(
tooltip: 'Manuell hinzufügen'), onPressed: _addCapDialog,
], icon: const Icon(Icons.add_circle_outline_rounded),
tooltip: 'Manuell hinzufügen'),
],
),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
_buildCatalogView(), _buildCatalogView(),
], ],
), ),
@@ -903,7 +983,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
return Column( return Column(
children: _catalog children: _catalog
.map( .map(
(entry) => Card( (entry) => _GlassCard(
child: ListTile( child: ListTile(
onTap: () => _editEntry(entry), onTap: () => _editEntry(entry),
leading: SizedBox( leading: SizedBox(
@@ -936,15 +1016,17 @@ class _MosaicHomePageState extends State<MosaicHomePage>
itemCount: _catalog.length, itemCount: _catalog.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
childAspectRatio: 1.2, // Mehr vertikaler Platz pro Card, damit Name/Hex/Delete nicht überlaufen.
childAspectRatio: 0.92,
mainAxisSpacing: 8, mainAxisSpacing: 8,
crossAxisSpacing: 8, crossAxisSpacing: 8,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = _catalog[index]; final entry = _catalog[index];
return Card( return _GlassCard(
clipBehavior: Clip.antiAlias, padding: EdgeInsets.zero,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => _editEntry(entry), onTap: () => _editEntry(entry),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -970,10 +1052,13 @@ class _MosaicHomePageState extends State<MosaicHomePage>
const Spacer(), const Spacer(),
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints.tightFor(width: 30, height: 30),
onPressed: _catalog.length <= 1 onPressed: _catalog.length <= 1
? null ? null
: () => _deleteEntry(entry), : () => _deleteEntry(entry),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline, size: 20),
), ),
], ],
), ),
@@ -1163,24 +1248,29 @@ class _CapPhotoReviewPageState extends State<_CapPhotoReviewPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( 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( body: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(14),
children: [ children: [
_CircleAdjustOverlay( _GlassCard(
imageBytes: widget.imageBytes, child: _CircleAdjustOverlay(
imageWidth: _imageW, imageBytes: widget.imageBytes,
imageHeight: _imageH, imageWidth: _imageW,
circleX: _circleX, imageHeight: _imageH,
circleY: _circleY, circleX: _circleX,
circleR: _circleR, circleY: _circleY,
onCircleChanged: (x, y, r) { circleR: _circleR,
setState(() { onCircleChanged: (x, y, r) {
_setAndClampCircle(x: x, y: y, r: r); setState(() {
}); _setAndClampCircle(x: x, y: y, r: r);
_scheduleLiveRecalculate(); });
}, _scheduleLiveRecalculate();
},
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -1311,6 +1401,104 @@ String _colorToHexStatic(Color color) {
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
} }
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 { class _CapThumb extends StatelessWidget {
final CapCatalogEntry entry; final CapCatalogEntry entry;
final bool large; final bool large;