feat: 4-step flow, robust project persistence, JSON export + QA/review docs #4
130
lib/main.dart
130
lib/main.dart
@@ -84,7 +84,7 @@ enum CatalogViewMode { list, grid }
|
||||
|
||||
enum ColorExtractionMode { dominant, average }
|
||||
|
||||
enum HomeSection { mosaic, catalog }
|
||||
enum HomeSection { mosaic, catalog, projects }
|
||||
|
||||
class MosaicHomePage extends StatefulWidget {
|
||||
const MosaicHomePage({super.key});
|
||||
@@ -116,6 +116,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
bool _isCaptureFlowInProgress = false;
|
||||
bool _isRecoveringCapture = false;
|
||||
bool _isProjectBusy = false;
|
||||
List<File> _projectFiles = [];
|
||||
|
||||
double _fidelityStructure = 0.5;
|
||||
double _ditheringStrength = 0.35;
|
||||
@@ -133,6 +134,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
||||
_capSizeCtrl.addListener(_scheduleRegenerate);
|
||||
_loadCatalog();
|
||||
_refreshProjectFiles();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_recoverCaptureOnResumeOrStart();
|
||||
_loadProject(silent: true);
|
||||
@@ -177,16 +179,42 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Future<File> _projectFile() async {
|
||||
Future<Directory> _projectsDir() async {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
return File('${docs.path}/mosaic_project.json');
|
||||
final dir = Directory('${docs.path}/projects');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
String _projectFilename() {
|
||||
final now = DateTime.now();
|
||||
final stamp = now
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll('.', '-')
|
||||
.replaceAll('T', '_');
|
||||
return 'project_$stamp.json';
|
||||
}
|
||||
|
||||
Future<void> _refreshProjectFiles() async {
|
||||
try {
|
||||
final dir = await _projectsDir();
|
||||
final files = dir
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.json'))
|
||||
.toList()
|
||||
..sort((a, b) => b.path.compareTo(a.path));
|
||||
if (mounted) setState(() => _projectFiles = files);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _saveProject({bool silent = false}) async {
|
||||
if (_isProjectBusy) return;
|
||||
_isProjectBusy = true;
|
||||
try {
|
||||
final file = await _projectFile();
|
||||
final projectsDir = await _projectsDir();
|
||||
final file = File('${projectsDir.path}/${_projectFilename()}');
|
||||
final payload = <String, dynamic>{
|
||||
'useCapSize': _useCapSize,
|
||||
'gridWidth': _gridWidthCtrl.text,
|
||||
@@ -197,11 +225,11 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
'edgeEmphasis': _edgeEmphasis,
|
||||
'colorVariation': _colorVariation,
|
||||
'selectedPreset': _selectedPreset.name,
|
||||
'activeSection': _activeSection.name,
|
||||
'sourceImageBase64':
|
||||
_sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!),
|
||||
};
|
||||
await file.writeAsString(jsonEncode(payload), flush: true);
|
||||
await _refreshProjectFiles();
|
||||
if (!silent && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Projekt gespeichert ✅')),
|
||||
@@ -218,15 +246,19 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadProject({bool silent = false}) async {
|
||||
Future<void> _loadProject({bool silent = false, File? fromFile}) async {
|
||||
if (_isProjectBusy) return;
|
||||
_isProjectBusy = true;
|
||||
try {
|
||||
final file = await _projectFile();
|
||||
File? file = fromFile;
|
||||
if (file == null) {
|
||||
await _refreshProjectFiles();
|
||||
if (_projectFiles.isEmpty) return;
|
||||
file = _projectFiles.first;
|
||||
}
|
||||
if (!await file.exists()) return;
|
||||
final data =
|
||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
|
||||
final data = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
final sourceB64 = data['sourceImageBase64'] as String?;
|
||||
final source = sourceB64 == null ? null : base64Decode(sourceB64);
|
||||
|
||||
@@ -253,21 +285,14 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
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;
|
||||
}
|
||||
_activeSection = HomeSection.mosaic;
|
||||
});
|
||||
|
||||
if (source != null) {
|
||||
await _generate();
|
||||
}
|
||||
if (source != null) await _generate();
|
||||
if (!silent && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Projekt geladen ✅')),
|
||||
@@ -284,6 +309,24 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteProject(File file) async {
|
||||
try {
|
||||
await file.delete();
|
||||
await _refreshProjectFiles();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Projekt gelöscht')),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Löschen fehlgeschlagen')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCatalog() async {
|
||||
final defaults = [
|
||||
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
|
||||
@@ -824,7 +867,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.45),
|
||||
title: Text(_activeSection == HomeSection.mosaic
|
||||
? 'Bottle-Cap Mosaic Studio'
|
||||
: 'Cap Catalog'),
|
||||
: _activeSection == HomeSection.catalog
|
||||
? 'Cap Catalog'
|
||||
: 'Projekte'),
|
||||
),
|
||||
floatingActionButton: _activeSection == HomeSection.mosaic
|
||||
? FloatingActionButton.extended(
|
||||
@@ -847,7 +892,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _activeSection == HomeSection.mosaic
|
||||
? _buildMosaicScreen()
|
||||
: _buildCatalogScreen(),
|
||||
: _activeSection == HomeSection.catalog
|
||||
? _buildCatalogScreen()
|
||||
: _buildProjectsScreen(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
@@ -866,11 +913,54 @@ class _MosaicHomePageState extends State<MosaicHomePage>
|
||||
selectedIcon: Icon(Icons.inventory_2),
|
||||
label: 'Catalog',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_copy_outlined),
|
||||
selectedIcon: Icon(Icons.folder_copy),
|
||||
label: 'Projekte',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectsScreen() {
|
||||
if (_projectFiles.isEmpty) {
|
||||
return const Center(child: Text('Noch keine gespeicherten Projekte.'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(14),
|
||||
itemCount: _projectFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = _projectFiles[index];
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMosaicScreen() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
|
||||
Reference in New Issue
Block a user