feat(mosaic): add zoom/pan source preview and project save/load

This commit is contained in:
gary
2026-02-22 16:09:38 +01:00
parent 84d649ac6d
commit b553c29d39

View File

@@ -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 {
@@ -695,6 +812,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
); );
_isGenerating = false; _isGenerating = false;
}); });
await _saveProject(silent: true);
} }
@override @override
@@ -758,7 +876,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 +889,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 +963,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 +983,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 +1047,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 +1070,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 +1220,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),
@@ -1469,7 +1636,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) {