feat(projects): add Projects tab with load/delete list

This commit is contained in:
gary
2026-02-23 08:25:06 +01:00
parent 3651b073bf
commit 814705cac6

View File

@@ -84,7 +84,7 @@ enum CatalogViewMode { list, grid }
enum ColorExtractionMode { dominant, average } enum ColorExtractionMode { dominant, average }
enum HomeSection { mosaic, catalog } enum HomeSection { mosaic, catalog, projects }
class MosaicHomePage extends StatefulWidget { class MosaicHomePage extends StatefulWidget {
const MosaicHomePage({super.key}); const MosaicHomePage({super.key});
@@ -116,6 +116,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
bool _isCaptureFlowInProgress = false; bool _isCaptureFlowInProgress = false;
bool _isRecoveringCapture = false; bool _isRecoveringCapture = false;
bool _isProjectBusy = false; bool _isProjectBusy = false;
List<File> _projectFiles = [];
double _fidelityStructure = 0.5; double _fidelityStructure = 0.5;
double _ditheringStrength = 0.35; double _ditheringStrength = 0.35;
@@ -133,6 +134,7 @@ class _MosaicHomePageState extends State<MosaicHomePage>
_gridHeightCtrl.addListener(_scheduleRegenerate); _gridHeightCtrl.addListener(_scheduleRegenerate);
_capSizeCtrl.addListener(_scheduleRegenerate); _capSizeCtrl.addListener(_scheduleRegenerate);
_loadCatalog(); _loadCatalog();
_refreshProjectFiles();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_recoverCaptureOnResumeOrStart(); _recoverCaptureOnResumeOrStart();
_loadProject(silent: true); _loadProject(silent: true);
@@ -177,16 +179,42 @@ class _MosaicHomePageState extends State<MosaicHomePage>
return file.path; return file.path;
} }
Future<File> _projectFile() async { Future<Directory> _projectsDir() async {
final docs = await getApplicationDocumentsDirectory(); 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 { Future<void> _saveProject({bool silent = false}) async {
if (_isProjectBusy) return; if (_isProjectBusy) return;
_isProjectBusy = true; _isProjectBusy = true;
try { try {
final file = await _projectFile(); final projectsDir = await _projectsDir();
final file = File('${projectsDir.path}/${_projectFilename()}');
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'useCapSize': _useCapSize, 'useCapSize': _useCapSize,
'gridWidth': _gridWidthCtrl.text, 'gridWidth': _gridWidthCtrl.text,
@@ -197,11 +225,11 @@ class _MosaicHomePageState extends State<MosaicHomePage>
'edgeEmphasis': _edgeEmphasis, 'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation, 'colorVariation': _colorVariation,
'selectedPreset': _selectedPreset.name, 'selectedPreset': _selectedPreset.name,
'activeSection': _activeSection.name,
'sourceImageBase64': 'sourceImageBase64':
_sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!), _sourceImageBytes == null ? null : base64Encode(_sourceImageBytes!),
}; };
await file.writeAsString(jsonEncode(payload), flush: true); await file.writeAsString(jsonEncode(payload), flush: true);
await _refreshProjectFiles();
if (!silent && mounted) { if (!silent && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Projekt gespeichert ✅')), 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; if (_isProjectBusy) return;
_isProjectBusy = true; _isProjectBusy = true;
try { 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; 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 sourceB64 = data['sourceImageBase64'] as String?;
final source = sourceB64 == null ? null : base64Decode(sourceB64); final source = sourceB64 == null ? null : base64Decode(sourceB64);
@@ -253,21 +285,14 @@ class _MosaicHomePageState extends State<MosaicHomePage>
orElse: () => _selectedPreset, orElse: () => _selectedPreset,
); );
final sectionName = data['activeSection'] as String?;
_activeSection = HomeSection.values.firstWhere(
(s) => s.name == sectionName,
orElse: () => _activeSection,
);
if (source != null) { if (source != null) {
_sourceImageBytes = source; _sourceImageBytes = source;
_result = null; _result = null;
} }
_activeSection = HomeSection.mosaic;
}); });
if (source != null) { if (source != null) await _generate();
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 ✅')),
@@ -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 { Future<void> _loadCatalog() async {
final defaults = [ final defaults = [
CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)), CapCatalogEntry.newEntry(name: 'White', color: const Color(0xFFF2F2F2)),
@@ -824,7 +867,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
backgroundColor: Colors.white.withValues(alpha: 0.45), backgroundColor: Colors.white.withValues(alpha: 0.45),
title: Text(_activeSection == HomeSection.mosaic title: Text(_activeSection == HomeSection.mosaic
? 'Bottle-Cap Mosaic Studio' ? 'Bottle-Cap Mosaic Studio'
: 'Cap Catalog'), : _activeSection == HomeSection.catalog
? 'Cap Catalog'
: 'Projekte'),
), ),
floatingActionButton: _activeSection == HomeSection.mosaic floatingActionButton: _activeSection == HomeSection.mosaic
? FloatingActionButton.extended( ? FloatingActionButton.extended(
@@ -847,7 +892,9 @@ class _MosaicHomePageState extends State<MosaicHomePage>
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _activeSection == HomeSection.mosaic : _activeSection == HomeSection.mosaic
? _buildMosaicScreen() ? _buildMosaicScreen()
: _buildCatalogScreen(), : _activeSection == HomeSection.catalog
? _buildCatalogScreen()
: _buildProjectsScreen(),
], ],
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -866,11 +913,54 @@ class _MosaicHomePageState extends State<MosaicHomePage>
selectedIcon: Icon(Icons.inventory_2), selectedIcon: Icon(Icons.inventory_2),
label: 'Catalog', 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() { Widget _buildMosaicScreen() {
return Padding( return Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),