feat(projects): add Projects tab with load/delete list
This commit is contained in:
130
lib/main.dart
130
lib/main.dart
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user