Implement 4-step mosaic flow, robust project autosave, and JSON export
This commit is contained in:
665
lib/main.dart
665
lib/main.dart
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:korken_mosaic/project_codec.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -86,6 +87,8 @@ enum ColorExtractionMode { dominant, average }
|
|||||||
|
|
||||||
enum HomeSection { mosaic, catalog, projects }
|
enum HomeSection { mosaic, catalog, projects }
|
||||||
|
|
||||||
|
enum MosaicFlowStep { image, size, colors, result }
|
||||||
|
|
||||||
class MosaicHomePage extends StatefulWidget {
|
class MosaicHomePage extends StatefulWidget {
|
||||||
const MosaicHomePage({super.key});
|
const MosaicHomePage({super.key});
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
bool _isRecoveringCapture = false;
|
bool _isRecoveringCapture = false;
|
||||||
bool _isProjectBusy = false;
|
bool _isProjectBusy = false;
|
||||||
List<File> _projectFiles = [];
|
List<File> _projectFiles = [];
|
||||||
|
MosaicFlowStep _currentFlowStep = MosaicFlowStep.image;
|
||||||
|
|
||||||
double _fidelityStructure = 0.5;
|
double _fidelityStructure = 0.5;
|
||||||
double _ditheringStrength = 0.35;
|
double _ditheringStrength = 0.35;
|
||||||
@@ -150,7 +154,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
if (state == AppLifecycleState.inactive ||
|
if (state == AppLifecycleState.inactive ||
|
||||||
state == AppLifecycleState.paused ||
|
state == AppLifecycleState.paused ||
|
||||||
state == AppLifecycleState.detached) {
|
state == AppLifecycleState.detached) {
|
||||||
_saveProject(silent: true);
|
_saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,43 +200,61 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
return 'project_$stamp.json';
|
return 'project_$stamp.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<File> _latestProjectFile() async {
|
||||||
|
final projectsDir = await _projectsDir();
|
||||||
|
return File('${projectsDir.path}/latest_project.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
MosaicProjectData _buildProjectData() {
|
||||||
|
return MosaicProjectData(
|
||||||
|
useCapSize: _useCapSize,
|
||||||
|
gridWidth: _gridWidthCtrl.text,
|
||||||
|
gridHeight: _gridHeightCtrl.text,
|
||||||
|
capSize: _capSizeCtrl.text,
|
||||||
|
fidelityStructure: _fidelityStructure,
|
||||||
|
ditheringStrength: _ditheringStrength,
|
||||||
|
edgeEmphasis: _edgeEmphasis,
|
||||||
|
colorVariation: _colorVariation,
|
||||||
|
selectedPreset: _selectedPreset.name,
|
||||||
|
sourceImageBytes: _sourceImageBytes,
|
||||||
|
savedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshProjectFiles() async {
|
Future<void> _refreshProjectFiles() async {
|
||||||
try {
|
try {
|
||||||
final dir = await _projectsDir();
|
final dir = await _projectsDir();
|
||||||
final files = dir
|
final files = dir
|
||||||
.listSync()
|
.listSync()
|
||||||
.whereType<File>()
|
.whereType<File>()
|
||||||
.where((f) => f.path.endsWith('.json'))
|
.where((f) => f.path.endsWith('.json') && !f.path.endsWith('latest_project.json'))
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => b.path.compareTo(a.path));
|
..sort((a, b) => b.path.compareTo(a.path));
|
||||||
if (mounted) setState(() => _projectFiles = files);
|
if (mounted) setState(() => _projectFiles = files);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveProject({bool silent = false}) async {
|
Future<void> _saveProject({bool silent = false, bool manual = true}) async {
|
||||||
if (_isProjectBusy) return;
|
if (_isProjectBusy) return;
|
||||||
_isProjectBusy = true;
|
_isProjectBusy = true;
|
||||||
try {
|
try {
|
||||||
final projectsDir = await _projectsDir();
|
final payload = _buildProjectData();
|
||||||
final file = File('${projectsDir.path}/${_projectFilename()}');
|
final latestFile = await _latestProjectFile();
|
||||||
final payload = <String, dynamic>{
|
await latestFile.writeAsString(jsonEncode(payload.toJson()), flush: true);
|
||||||
'useCapSize': _useCapSize,
|
|
||||||
'gridWidth': _gridWidthCtrl.text,
|
if (manual) {
|
||||||
'gridHeight': _gridHeightCtrl.text,
|
final projectsDir = await _projectsDir();
|
||||||
'capSize': _capSizeCtrl.text,
|
final snapshot = File('${projectsDir.path}/${_projectFilename()}');
|
||||||
'fidelityStructure': _fidelityStructure,
|
await snapshot.writeAsString(jsonEncode(payload.toJson()), flush: true);
|
||||||
'ditheringStrength': _ditheringStrength,
|
}
|
||||||
'edgeEmphasis': _edgeEmphasis,
|
|
||||||
'colorVariation': _colorVariation,
|
|
||||||
'selectedPreset': _selectedPreset.name,
|
|
||||||
'sourceImageBase64':
|
|
||||||
_sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!),
|
|
||||||
};
|
|
||||||
await file.writeAsString(jsonEncode(payload), flush: true);
|
|
||||||
await _refreshProjectFiles();
|
await _refreshProjectFiles();
|
||||||
if (!silent && mounted) {
|
if (!silent && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Projekt gespeichert ✅')),
|
SnackBar(
|
||||||
|
content: Text(manual
|
||||||
|
? 'Projekt gespeichert ✅'
|
||||||
|
: 'Projekt automatisch gesichert')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -252,47 +274,46 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
try {
|
try {
|
||||||
File? file = fromFile;
|
File? file = fromFile;
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
await _refreshProjectFiles();
|
final latest = await _latestProjectFile();
|
||||||
if (_projectFiles.isEmpty) return;
|
if (await latest.exists()) {
|
||||||
file = _projectFiles.first;
|
file = latest;
|
||||||
|
} else {
|
||||||
|
await _refreshProjectFiles();
|
||||||
|
if (_projectFiles.isEmpty) return;
|
||||||
|
file = _projectFiles.first;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!await file.exists()) return;
|
if (!await file.exists()) return;
|
||||||
|
|
||||||
final data = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
final data = MosaicProjectData.fromJson(
|
||||||
final sourceB64 = data['sourceImageBase64'] as String?;
|
jsonDecode(await file.readAsString()) as Map<String, dynamic>,
|
||||||
final source = sourceB64 == null ? null : base64Decode(sourceB64);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_useCapSize = data['useCapSize'] as bool? ?? _useCapSize;
|
_useCapSize = data.useCapSize;
|
||||||
_gridWidthCtrl.text =
|
_gridWidthCtrl.text = data.gridWidth;
|
||||||
data['gridWidth'] as String? ?? _gridWidthCtrl.text;
|
_gridHeightCtrl.text = data.gridHeight;
|
||||||
_gridHeightCtrl.text =
|
_capSizeCtrl.text = data.capSize;
|
||||||
data['gridHeight'] as String? ?? _gridHeightCtrl.text;
|
_fidelityStructure = data.fidelityStructure;
|
||||||
_capSizeCtrl.text = data['capSize'] as String? ?? _capSizeCtrl.text;
|
_ditheringStrength = data.ditheringStrength;
|
||||||
_fidelityStructure = (data['fidelityStructure'] as num?)?.toDouble() ??
|
_edgeEmphasis = data.edgeEmphasis;
|
||||||
_fidelityStructure;
|
_colorVariation = data.colorVariation;
|
||||||
_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(
|
_selectedPreset = StylePreset.values.firstWhere(
|
||||||
(p) => p.name == presetName,
|
(p) => p.name == data.selectedPreset,
|
||||||
orElse: () => _selectedPreset,
|
orElse: () => StylePreset.ausgewogen,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (source != null) {
|
if (data.sourceImageBytes != null) {
|
||||||
_sourceImageBytes = source;
|
_sourceImageBytes = data.sourceImageBytes;
|
||||||
_result = null;
|
_result = null;
|
||||||
|
_currentFlowStep = MosaicFlowStep.result;
|
||||||
}
|
}
|
||||||
_activeSection = HomeSection.mosaic;
|
_activeSection = HomeSection.mosaic;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source != null) await _generate();
|
if (data.sourceImageBytes != null) await _generate();
|
||||||
if (!silent && mounted) {
|
if (!silent && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Projekt geladen ✅')),
|
const SnackBar(content: Text('Projekt geladen ✅')),
|
||||||
@@ -310,6 +331,24 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteProject(File file) async {
|
Future<void> _deleteProject(File file) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Projekt löschen?'),
|
||||||
|
content: Text(file.path.split('/').last),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Abbrechen')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Löschen')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
await _refreshProjectFiles();
|
await _refreshProjectFiles();
|
||||||
@@ -327,6 +366,43 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _exportProjectJson() async {
|
||||||
|
if (_sourceImageBytes == null) return;
|
||||||
|
try {
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory('${docs.path}/exports');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final now = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||||
|
final file = File('${dir.path}/mosaic_export_$now.json');
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
'project': _buildProjectData().toJson(),
|
||||||
|
if (_result != null)
|
||||||
|
'result': {
|
||||||
|
'width': _result!.width,
|
||||||
|
'height': _result!.height,
|
||||||
|
'assignments': _result!.assignments,
|
||||||
|
'palette': _result!.palette
|
||||||
|
.map((c) => {
|
||||||
|
'name': c.name,
|
||||||
|
'value': c.color.toARGB32(),
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await file.writeAsString(jsonEncode(payload), flush: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('JSON exportiert: ${file.path}')),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Export fehlgeschlagen')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)),
|
||||||
@@ -369,8 +445,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sourceImageBytes = bytes;
|
_sourceImageBytes = bytes;
|
||||||
_result = null;
|
_result = null;
|
||||||
|
_currentFlowStep = MosaicFlowStep.size;
|
||||||
});
|
});
|
||||||
await _saveProject(silent: true);
|
await _saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<File> _pendingCaptureFile() async {
|
Future<File> _pendingCaptureFile() async {
|
||||||
@@ -856,7 +933,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
);
|
);
|
||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
});
|
});
|
||||||
await _saveProject(silent: true);
|
await _saveProject(silent: true, manual: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -924,40 +1001,61 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProjectsScreen() {
|
Widget _buildProjectsScreen() {
|
||||||
if (_projectFiles.isEmpty) {
|
return ListView(
|
||||||
return const Center(child: Text('Noch keine gespeicherten Projekte.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
itemCount: _projectFiles.length,
|
children: [
|
||||||
itemBuilder: (context, index) {
|
_GlassCard(
|
||||||
final file = _projectFiles[index];
|
child: Wrap(
|
||||||
final name = file.uri.pathSegments.isEmpty
|
spacing: 8,
|
||||||
? file.path
|
runSpacing: 8,
|
||||||
: file.uri.pathSegments.last;
|
children: [
|
||||||
return Card(
|
FilledButton.icon(
|
||||||
child: ListTile(
|
onPressed: () => _loadProject(),
|
||||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
icon: const Icon(Icons.history),
|
||||||
title: Text(name),
|
label: const Text('Letzten Stand laden'),
|
||||||
trailing: Wrap(
|
),
|
||||||
spacing: 8,
|
OutlinedButton.icon(
|
||||||
children: [
|
onPressed: _refreshProjectFiles,
|
||||||
IconButton(
|
icon: const Icon(Icons.refresh),
|
||||||
tooltip: 'Laden',
|
label: const Text('Aktualisieren'),
|
||||||
icon: const Icon(Icons.playlist_add_check_circle_outlined),
|
),
|
||||||
onPressed: () => _loadProject(fromFile: file),
|
],
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Löschen',
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
onPressed: () => _deleteProject(file),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
const SizedBox(height: 8),
|
||||||
|
if (_projectFiles.isEmpty)
|
||||||
|
const Center(child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('Noch keine gespeicherten Snapshots.'),
|
||||||
|
))
|
||||||
|
else
|
||||||
|
..._projectFiles.map((file) {
|
||||||
|
final name = file.uri.pathSegments.isEmpty
|
||||||
|
? file.path
|
||||||
|
: file.uri.pathSegments.last;
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||||
|
title: Text(name),
|
||||||
|
trailing: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Laden',
|
||||||
|
icon: const Icon(Icons.playlist_add_check_circle_outlined),
|
||||||
|
onPressed: () => _loadProject(fromFile: file),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Löschen',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _deleteProject(file),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,220 +1065,221 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
_GlassCard(
|
_GlassCard(
|
||||||
child: Column(
|
child: Stepper(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
currentStep: _currentFlowStep.index,
|
||||||
children: [
|
controlsBuilder: (context, details) {
|
||||||
Wrap(
|
final isLast = _currentFlowStep == MosaicFlowStep.result;
|
||||||
runSpacing: 10,
|
return Row(
|
||||||
spacing: 10,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
FilledButton.icon(
|
FilledButton(
|
||||||
onPressed: _pickImage,
|
onPressed: details.onStepContinue,
|
||||||
icon: const Icon(Icons.image_outlined),
|
child: Text(isLast ? 'Fertig' : 'Weiter'),
|
||||||
label: const Text('Import target image'),
|
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
const SizedBox(width: 8),
|
||||||
onPressed: () => _saveProject(),
|
if (_currentFlowStep.index > 0)
|
||||||
icon: const Icon(Icons.save_outlined),
|
OutlinedButton(
|
||||||
label: const Text('Speichern'),
|
onPressed: details.onStepCancel,
|
||||||
),
|
child: const Text('Zurück'),
|
||||||
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(
|
onStepContinue: () {
|
||||||
'Pinch zum Zoomen, mit einem Finger verschieben',
|
setState(() {
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
final next = _currentFlowStep.index + 1;
|
||||||
),
|
if (next <= MosaicFlowStep.result.index) {
|
||||||
],
|
_currentFlowStep = MosaicFlowStep.values[next];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStepCancel: () {
|
||||||
|
setState(() {
|
||||||
|
final prev = _currentFlowStep.index - 1;
|
||||||
|
if (prev >= 0) {
|
||||||
|
_currentFlowStep = MosaicFlowStep.values[prev];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStepTapped: (index) {
|
||||||
|
setState(() => _currentFlowStep = MosaicFlowStep.values[index]);
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
Step(
|
||||||
|
title: const Text('1) Bild'),
|
||||||
|
isActive: _currentFlowStep.index >= 0,
|
||||||
|
content: _buildImageStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('2) Größe'),
|
||||||
|
isActive: _currentFlowStep.index >= 1,
|
||||||
|
content: _buildSizeStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('3) Farben'),
|
||||||
|
isActive: _currentFlowStep.index >= 2,
|
||||||
|
content: _buildColorStep(),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
title: const Text('4) Ergebnis'),
|
||||||
|
isActive: _currentFlowStep.index >= 3,
|
||||||
|
content: _buildResultStep(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
_GlassCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SegmentedButton<bool>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(value: false, label: Text('Grid W x H')),
|
|
||||||
ButtonSegment(value: true, label: Text('Cap size (px)')),
|
|
||||||
],
|
|
||||||
selected: {_useCapSize},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _useCapSize = s.first);
|
|
||||||
_scheduleRegenerate();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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)'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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')),
|
|
||||||
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(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Preview (${_result!.width} x ${_result!.height})',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: _result!.width / _result!.height,
|
|
||||||
child: Image.memory(_result!.previewPng,
|
|
||||||
fit: BoxFit.fill,
|
|
||||||
filterQuality: FilterQuality.none,
|
|
||||||
gaplessPlayback: true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
leading: CircleAvatar(backgroundColor: e.key.color),
|
|
||||||
title: Text(e.key.name),
|
|
||||||
trailing: Text('${e.value} caps'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildImageStep() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('pick-image-btn'),
|
||||||
|
onPressed: _pickImage,
|
||||||
|
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 SizedBox(height: 8),
|
||||||
|
const Chip(label: Text('Image loaded ✅')),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSizeStep() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: false, label: Text('Grid W x H')),
|
||||||
|
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: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _gridWidthCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: 'Grid Width')),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
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)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColorStep() {
|
||||||
|
return 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')),
|
||||||
|
ButtonSegment(value: StylePreset.ausgewogen, label: Text('Ausgewogen')),
|
||||||
|
ButtonSegment(value: StylePreset.kuenstlerisch, label: Text('Künstlerisch')),
|
||||||
|
],
|
||||||
|
selected: {_selectedPreset},
|
||||||
|
onSelectionChanged: (s) => _applyPreset(s.first),
|
||||||
|
),
|
||||||
|
Text('Aktive Katalogfarben: ${_catalog.length}'),
|
||||||
|
_SliderRow(
|
||||||
|
label: 'Fidelity ↔ Structure',
|
||||||
|
leftLabel: 'Fidelity',
|
||||||
|
rightLabel: 'Structure',
|
||||||
|
value: _fidelityStructure,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _fidelityStructure = v);
|
||||||
|
_onStyleChanged();
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultStep() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('generate-btn'),
|
||||||
|
onPressed: _isGenerating ? null : _generate,
|
||||||
|
icon: const Icon(Icons.auto_fix_high_rounded),
|
||||||
|
label: const Text('Generate Mosaic'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _exportProjectJson,
|
||||||
|
icon: const Icon(Icons.file_download_outlined),
|
||||||
|
label: const Text('Export JSON'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_result != null) ...[
|
||||||
|
Text('Preview (${_result!.width} x ${_result!.height})'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Image.memory(_result!.previewPng, gaplessPlayback: true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._result!.sortedCounts.take(8).map(
|
||||||
|
(e) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(backgroundColor: e.key.color),
|
||||||
|
title: Text(e.key.name),
|
||||||
|
trailing: Text('${e.value} caps'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildCatalogScreen() {
|
Widget _buildCatalogScreen() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
|
|||||||
62
lib/project_codec.dart
Normal file
62
lib/project_codec.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class MosaicProjectData {
|
||||||
|
final bool useCapSize;
|
||||||
|
final String gridWidth;
|
||||||
|
final String gridHeight;
|
||||||
|
final String capSize;
|
||||||
|
final double fidelityStructure;
|
||||||
|
final double ditheringStrength;
|
||||||
|
final double edgeEmphasis;
|
||||||
|
final double colorVariation;
|
||||||
|
final String selectedPreset;
|
||||||
|
final Uint8List? sourceImageBytes;
|
||||||
|
final DateTime savedAt;
|
||||||
|
|
||||||
|
const MosaicProjectData({
|
||||||
|
required this.useCapSize,
|
||||||
|
required this.gridWidth,
|
||||||
|
required this.gridHeight,
|
||||||
|
required this.capSize,
|
||||||
|
required this.fidelityStructure,
|
||||||
|
required this.ditheringStrength,
|
||||||
|
required this.edgeEmphasis,
|
||||||
|
required this.colorVariation,
|
||||||
|
required this.selectedPreset,
|
||||||
|
required this.sourceImageBytes,
|
||||||
|
required this.savedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'useCapSize': useCapSize,
|
||||||
|
'gridWidth': gridWidth,
|
||||||
|
'gridHeight': gridHeight,
|
||||||
|
'capSize': capSize,
|
||||||
|
'fidelityStructure': fidelityStructure,
|
||||||
|
'ditheringStrength': ditheringStrength,
|
||||||
|
'edgeEmphasis': edgeEmphasis,
|
||||||
|
'colorVariation': colorVariation,
|
||||||
|
'selectedPreset': selectedPreset,
|
||||||
|
'sourceImageBase64':
|
||||||
|
sourceImageBytes == null ? null : base64Encode(sourceImageBytes!),
|
||||||
|
'savedAt': savedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory MosaicProjectData.fromJson(Map<String, dynamic> json) {
|
||||||
|
final sourceB64 = json['sourceImageBase64'] as String?;
|
||||||
|
return MosaicProjectData(
|
||||||
|
useCapSize: json['useCapSize'] as bool? ?? false,
|
||||||
|
gridWidth: json['gridWidth'] as String? ?? '40',
|
||||||
|
gridHeight: json['gridHeight'] as String? ?? '30',
|
||||||
|
capSize: json['capSize'] as String? ?? '12',
|
||||||
|
fidelityStructure: (json['fidelityStructure'] as num?)?.toDouble() ?? 0.5,
|
||||||
|
ditheringStrength: (json['ditheringStrength'] as num?)?.toDouble() ?? 0.35,
|
||||||
|
edgeEmphasis: (json['edgeEmphasis'] as num?)?.toDouble() ?? 0.4,
|
||||||
|
colorVariation: (json['colorVariation'] as num?)?.toDouble() ?? 0.3,
|
||||||
|
selectedPreset: json['selectedPreset'] as String? ?? 'ausgewogen',
|
||||||
|
sourceImageBytes: sourceB64 == null ? null : base64Decode(sourceB64),
|
||||||
|
savedAt: DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
test/project_codec_test.dart
Normal file
32
test/project_codec_test.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:korken_mosaic/project_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('MosaicProjectData json roundtrip keeps values', () {
|
||||||
|
final original = MosaicProjectData(
|
||||||
|
useCapSize: true,
|
||||||
|
gridWidth: '50',
|
||||||
|
gridHeight: '40',
|
||||||
|
capSize: '10',
|
||||||
|
fidelityStructure: 0.3,
|
||||||
|
ditheringStrength: 0.2,
|
||||||
|
edgeEmphasis: 0.4,
|
||||||
|
colorVariation: 0.5,
|
||||||
|
selectedPreset: 'realistisch',
|
||||||
|
sourceImageBytes: Uint8List.fromList([1, 2, 3]),
|
||||||
|
savedAt: DateTime.parse('2026-01-01T12:00:00Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = MosaicProjectData.fromJson(original.toJson());
|
||||||
|
|
||||||
|
expect(decoded.useCapSize, isTrue);
|
||||||
|
expect(decoded.gridWidth, '50');
|
||||||
|
expect(decoded.gridHeight, '40');
|
||||||
|
expect(decoded.capSize, '10');
|
||||||
|
expect(decoded.selectedPreset, 'realistisch');
|
||||||
|
expect(decoded.sourceImageBytes, isNotNull);
|
||||||
|
expect(decoded.sourceImageBytes!, [1, 2, 3]);
|
||||||
|
});
|
||||||
|
}
|
||||||
24
test/widget_test.dart
Normal file
24
test/widget_test.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:korken_mosaic/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows 4-step workflow and can navigate to result step',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const KorkenMosaicApp());
|
||||||
|
|
||||||
|
expect(find.text('1) Bild'), findsOneWidget);
|
||||||
|
expect(find.text('2) Größe'), findsOneWidget);
|
||||||
|
expect(find.text('3) Farben'), findsOneWidget);
|
||||||
|
expect(find.text('4) Ergebnis'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Weiter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('generate-btn')), findsOneWidget);
|
||||||
|
expect(find.text('Export JSON'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user