feat(mosaic): add zoom/pan source preview and project save/load
This commit is contained in:
194
lib/main.dart
194
lib/main.dart
@@ -62,7 +62,8 @@ class KorkenMosaicApp extends StatelessWidget {
|
||||
indicatorColor: const Color(0x804FD6E8),
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.76),
|
||||
labelTextStyle: WidgetStatePropertyAll(
|
||||
TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
TextStyle(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
sliderTheme: SliderThemeData(
|
||||
@@ -114,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;
|
||||
@@ -133,6 +135,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
_loadCatalog();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_recoverCaptureOnResumeOrStart();
|
||||
_loadProject(silent: true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,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)),
|
||||
@@ -211,6 +327,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
_sourceImageBytes = bytes;
|
||||
_result = null;
|
||||
});
|
||||
await _saveProject(silent: true);
|
||||
}
|
||||
|
||||
Future<File> _pendingCaptureFile() async {
|
||||
@@ -695,6 +812,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
);
|
||||
_isGenerating = false;
|
||||
});
|
||||
await _saveProject(silent: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -758,7 +876,10 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
child: ListView(
|
||||
children: [
|
||||
_GlassCard(
|
||||
child: Wrap(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
runSpacing: 10,
|
||||
spacing: 10,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
@@ -768,10 +889,47 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
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)
|
||||
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(
|
||||
@@ -805,8 +963,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
child: TextField(
|
||||
controller: _gridHeightCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Grid Height')),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Grid Height')),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -825,13 +983,20 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Style Preset', style: Theme.of(context).textTheme.titleMedium),
|
||||
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')),
|
||||
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),
|
||||
@@ -882,7 +1047,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
@@ -904,7 +1070,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
..._result!.sortedCounts.map(
|
||||
(e) => ListTile(
|
||||
@@ -1053,8 +1220,8 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints.tightFor(width: 30, height: 30),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 30, height: 30),
|
||||
onPressed: _catalog.length <= 1
|
||||
? null
|
||||
: () => _deleteEntry(entry),
|
||||
@@ -1469,7 +1636,8 @@ class _GlassCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user