creek

The AI Image Editor of 2030
commit c1729cfeb806830f4dc75c683e2f4df110166a64
parent 0bfb142985550fc2d9a5eabe6ecf10c803aa0f8b
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sun, 30 Nov 2025 01:04:03 +0530

Implement persistent analysis queue and crash-proof sharing

- Implemented a database-backed queue to manage image analysis, ensuring tasks persist across app restarts
- Updated database schema to track analysis status and added a hidden 'Inbox' for temporary storage
- Refactored the share handler to immediately persist files to disk/DB to prevent data loss on app exit
- Configured app startup to automatically resume any pending or interrupted analysis tasks

Diffstat:
Mlib/data/database.dart | 9++++++++-
Mlib/data/models/image_model.dart | 4++++
Mlib/data/repos/image_repo.dart | 45+++++++++++++++++++++++++++++++++++++++++++--
Mlib/data/repos/project_repo.dart | 12++++++++----
Mlib/main.dart | 4+++-
Alib/services/analysis_queue_manager.dart | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/services/image_service.dart | 102+++++++++++++++++++------------------------------------------------------------
Mlib/ui/pages/image_save_page.dart | 2+-
Mlib/ui/pages/share_handler_page.dart | 33++++++++++++++++++++++++++-------
9 files changed, 182 insertions(+), 94 deletions(-)

diff --git a/lib/data/database.dart b/lib/data/database.dart @@ -9,7 +9,7 @@ import 'models/note_model.dart'; class AppDatabase { static Database? _db; - static const String _dbName = 'database_v3.db'; + static const String _dbName = 'database_v5.db'; static Future<Database> get db async { if (_db != null) return _db!; @@ -41,6 +41,12 @@ class AppDatabase { ) '''); + // Create Inbox Project (ID 0) for Drafts + await db.rawInsert(''' + INSERT INTO projects (id, title, description, last_accessed_at, created_at) + VALUES (0, 'Inbox', 'Holding area for shared images', ?, ?) + ''', [DateTime.now().toIso8601String(), DateTime.now().toIso8601String()]); + // 2. IMAGES (Moodboard) await db.execute(''' CREATE TABLE images ( @@ -51,6 +57,7 @@ class AppDatabase { tags TEXT DEFAULT '[]', analysis_data TEXT, created_at TEXT, + status TEXT DEFAULT 'pending', FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE ) '''); diff --git a/lib/data/models/image_model.dart b/lib/data/models/image_model.dart @@ -8,6 +8,7 @@ class ImageModel { final List<String> tags; final String? analysisData; final DateTime createdAt; + final String status; // NEW: 'pending', 'analyzing', 'completed', 'failed' ImageModel({ required this.id, @@ -17,6 +18,7 @@ class ImageModel { this.tags = const [], this.analysisData, required this.createdAt, + this.status = 'pending', }); Map<String, dynamic> toMap() { @@ -28,6 +30,7 @@ class ImageModel { 'tags': jsonEncode(tags), 'analysis_data': analysisData, 'created_at': createdAt.toIso8601String(), + 'status': status, }; } @@ -40,6 +43,7 @@ class ImageModel { tags: map['tags'] != null ? List<String>.from(jsonDecode(map['tags'])) : [], analysisData: map['analysis_data'], createdAt: DateTime.parse(map['created_at']), + status: map['status'] ?? 'pending', ); } } diff --git a/lib/data/repos/image_repo.dart b/lib/data/repos/image_repo.dart @@ -26,7 +26,48 @@ class ImageRepo { return null; } - /// NEW METHOD: Fetches tags for the specific image + Future<ImageModel?> getByFilePath(String path) async { + final db = await AppDatabase.db; + final res = await db.query( + 'images', + where: 'file_path = ?', + whereArgs: [path], + limit: 1, + ); + if (res.isNotEmpty) return ImageModel.fromMap(res.first); + return null; + } + + Future<void> updateProject(String id, int projectId) async { + final db = await AppDatabase.db; + await db.update( + 'images', + {'project_id': projectId}, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future<List<ImageModel>> getPendingImages() async { + final db = await AppDatabase.db; + final res = await db.query( + 'images', + where: 'status = ? OR status = ?', + whereArgs: ['pending', 'analyzing'], + ); + return res.map((e) => ImageModel.fromMap(e)).toList(); + } + + Future<void> updateStatus(String id, String status) async { + final db = await AppDatabase.db; + await db.update( + 'images', + {'status': status}, + where: 'id = ?', + whereArgs: [id], + ); + } + Future<List<String>> getTagsForImage(dynamic id) async { final db = await AppDatabase.db; final res = await db.query( @@ -77,7 +118,7 @@ class ImageRepo { await db.delete('images', where: 'id = ?', whereArgs: [id]); } - /// Helper for Project Deletion Service + // Helper for Project Deletion Service Future<List<String>> getAllFilePathsForProjectIds( List<int> projectIds, ) async { diff --git a/lib/data/repos/project_repo.dart b/lib/data/repos/project_repo.dart @@ -11,7 +11,7 @@ class ProjectRepo { final db = await AppDatabase.db; final res = await db.query( 'projects', - where: 'parent_id IS NULL', + where: 'parent_id IS NULL AND id != 0', orderBy: 'last_accessed_at DESC', limit: lim, ); @@ -22,7 +22,7 @@ class ProjectRepo { final db = await AppDatabase.db; final res = await db.query( 'projects', - // No 'where parent_id is null' check here, we want everything + where: 'id != 0', orderBy: 'last_accessed_at DESC', limit: 10, ); @@ -31,7 +31,11 @@ class ProjectRepo { Future<List<ProjectModel>> getAllProjectsAndEvents() async { final db = await AppDatabase.db; - final res = await db.query('projects', orderBy: 'title ASC'); + final res = await db.query( + 'projects', + where: 'id != 0', + orderBy: 'title ASC' + ); return res.map((e) => ProjectModel.fromMap(e)).toList(); } @@ -39,7 +43,7 @@ class ProjectRepo { final db = await AppDatabase.db; final res = await db.query( 'projects', - where: 'parent_id IS NULL', + where: 'parent_id IS NULL AND id != 0', orderBy: 'last_accessed_at DESC', ); return res.map((e) => ProjectModel.fromMap(e)).toList(); diff --git a/lib/main.dart b/lib/main.dart @@ -4,9 +4,11 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:adobe/services/theme_service.dart'; import 'package:adobe/ui/pages/share_handler_page.dart'; import 'package:adobe/ui/pages/home_page.dart'; +import 'package:adobe/services/analysis_queue_manager.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); + AnalysisQueueManager().processQueue(); runApp(const MyApp()); } diff --git a/lib/services/analysis_queue_manager.dart b/lib/services/analysis_queue_manager.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import '../data/models/image_model.dart'; +import '../data/repos/image_repo.dart'; +import 'analyze/image_analyzer.dart'; + +class AnalysisQueueManager { + static final AnalysisQueueManager _instance = AnalysisQueueManager._internal(); + factory AnalysisQueueManager() => _instance; + AnalysisQueueManager._internal(); + + bool _isProcessing = false; + final _repo = ImageRepo(); + Future<void> processQueue() async { + if (_isProcessing) return; + _isProcessing = true; + + try { + // 1. Fetch pending work from DB + List<ImageModel> pending = await _repo.getPendingImages(); + + if (pending.isEmpty) { + _isProcessing = false; + return; + } + + debugPrint("[Queue]: Found ${pending.length} pending images"); + for (final image in pending) { + await _repo.updateStatus(image.id, 'analyzing'); + try { + Map<String, dynamic> result; + + // 2. Run Analysis + if (image.tags.isEmpty) { + debugPrint("[Queue]: Analyzing Full Suite: ${image.name}"); + result = await ImageAnalyzerService.analyzeFullSuite(image.filePath); + } else { + debugPrint("[Queue]: Analyzing Selected: ${image.name} with tags: ${image.tags}"); + result = await ImageAnalyzerService.analyzeSelected(image.filePath, image.tags); + } + + // 3. Handle Result + if (result['success'] == true && result['data'] != null) { + final resultsMap = result['data']['results'] ?? {}; + final jsonString = jsonEncode(resultsMap); + await _repo.updateAnalysis(image.id, jsonString); + await _repo.updateStatus(image.id, 'completed'); + debugPrint("[Queue]: Completed: ${image.name}"); + } else { + await _repo.updateStatus(image.id, 'failed'); + debugPrint("[Queue]: Failed: ${result['error']}"); + } + + } catch (e) { + debugPrint("[Queue]: Analysis Exception: $e"); + await _repo.updateStatus(image.id, 'failed'); + } + } + } catch (e) { + debugPrint("[Queue]: Critical Error: $e"); + } finally { + _isProcessing = false; + } + } +} diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart @@ -6,56 +6,28 @@ import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import '../data/repos/image_repo.dart'; import '../data/models/image_model.dart'; -import 'analyze/image_analyzer.dart'; +import 'analysis_queue_manager.dart'; class ImageService { final ImageRepo _repo = ImageRepo(); final Uuid _uuid = const Uuid(); - // ANALYSIS QUEUE - // Static queue ensures all instances share the same processing line - static final List<Future<void> Function()> _analysisQueue = []; - static bool _isProcessingQueue = false; - - static void _enqueueAnalysis( - String debugLabel, - Future<void> Function() task, - ) { - debugPrint( - "[Queue] Enqueueing task: $debugLabel. (Queue size: ${_analysisQueue.length + 1})", - ); - _analysisQueue.add(task); - - if (!_isProcessingQueue) { - _processQueue(); - } - } - - static Future<void> _processQueue() async { - if (_isProcessingQueue) return; - _isProcessingQueue = true; - - debugPrint("[Queue] Queue processor started."); - - while (_analysisQueue.isNotEmpty) { - final task = _analysisQueue.removeAt(0); - - try { - debugPrint( - "[Queue] Starting next task. Remaining in queue: ${_analysisQueue.length}", - ); - await task(); - debugPrint("[Queue] Task completed"); - } catch (e) { - debugPrint("[Queue] Task failed: $e"); - } - - // Small delay to let the UI update between heavy jobs - await Future.delayed(const Duration(milliseconds: 100)); + // Checks if image is a draft (from ShareHandler) and updates it, or saves new + Future<String> saveOrUpdateImage( + File file, + int projectId, { + List<String> tags = const [], + }) async { + final existing = await _repo.getByFilePath(file.path); + if (existing != null) { + debugPrint("ImageService: Updating existing draft ${existing.id} -> Project $projectId"); + await _repo.updateProject(existing.id, projectId); + await updateTags(existing.id, tags); + return existing.id; + } else { + debugPrint("ImageService: Saving new image"); + return await saveImage(file, projectId, tags: tags); } - - debugPrint("[Queue] Queue empty. All background jobs finished."); - _isProcessingQueue = false; } // --- PUBLIC METHODS --- @@ -65,6 +37,7 @@ class ImageService { File file, int projectId, { List<String> tags = const [], + String? status, }) async { // 1. Prepare Directory final dir = await getApplicationDocumentsDirectory(); @@ -90,12 +63,13 @@ class ImageService { filePath: newPath, name: file.path.split('/').last, createdAt: DateTime.now(), - tags: [], + tags: tags, + status: status ?? 'pending', ); await _repo.addImage(image); - // 4. Update Tags & Trigger Analysis - await updateTags(id, tags); + // 4. Trigger Analysis Queue (Always) + AnalysisQueueManager().processQueue(); return id; } @@ -119,37 +93,9 @@ class ImageService { // 1. Update Tags in Database await _repo.updateTags(imageId, newTags); - // 2. Fetch Image path - final image = await _repo.getById(imageId); - if (image != null) { - // 3. Enqueue Analysis - _enqueueAnalysis("Analyze $imageId", () async { - await _analyzeInBackground(imageId, image.filePath, tags: newTags); - }); - } - } - - Future<void> _analyzeInBackground( - String imageId, - String filePath, { - List<String> tags = const [], - }) async { - try { - Map<String, dynamic>? result; - if (tags.isEmpty) { - result = await ImageAnalyzerService.analyzeFullSuite(filePath); - } else { - result = await ImageAnalyzerService.analyzeSelected(filePath, tags); - } - - if (result != null) { - final String jsonString = jsonEncode(result); - await _repo.updateAnalysis(imageId, jsonString); - } - } catch (e) { - // Re-throw so the queue knows it failed - throw Exception("Analysis failed for $imageId: $e"); - } + // 2. Mark as pending and trigger queue + await _repo.updateStatus(imageId, 'pending'); + AnalysisQueueManager().processQueue(); } Future<void> updateAnalysis(String id, Map<String, dynamic> analysis) async { diff --git a/lib/ui/pages/image_save_page.dart b/lib/ui/pages/image_save_page.dart @@ -553,7 +553,7 @@ class _ImageSavePageState extends State<ImageSavePage> { // 1. Save Image final tags = _tagsPerImage[i] ?? {}; - final imageId = await _imageService.saveImage( + final imageId = await _imageService.saveOrUpdateImage( file, widget.projectId, tags: tags.toList(), diff --git a/lib/ui/pages/share_handler_page.dart b/lib/ui/pages/share_handler_page.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:adobe/services/download_service.dart'; import 'package:adobe/services/instagram_download_service.dart'; +import 'package:adobe/services/image_service.dart'; import 'package:adobe/ui/pages/share_to_moodboard_page.dart'; class ShareHandlerPage extends StatefulWidget { @@ -17,6 +18,7 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { // Services final _downloadService = DownloadService(); final _instagramService = InstagramDownloadService(); + final _imageService = ImageService(); // State bool _hasError = false; @@ -30,12 +32,12 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { Future<void> _processSharedContent() async { final sharedContent = widget.sharedText.trim(); - List<File> finalFiles = []; + List<File> tempFiles = []; try { // CASE A: It's a Local File Path if (await File(sharedContent).exists()) { - finalFiles.add(File(sharedContent)); + tempFiles.add(File(sharedContent)); } // CASE B: It's a URL else { @@ -49,13 +51,13 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { // Instagram Logic final downloadedPaths = await _instagramService.downloadInstagramImage(url); if (downloadedPaths != null && downloadedPaths.isNotEmpty) { - finalFiles.addAll(downloadedPaths.map((path) => File(path))); + tempFiles.addAll(downloadedPaths.map((path) => File(path))); } } else { // Generic Download Logic final savedPath = await _downloadService.downloadAndSaveImage(url); if (savedPath != null) { - finalFiles.add(File(savedPath)); + tempFiles.add(File(savedPath)); } } } else { @@ -63,13 +65,30 @@ class _ShareHandlerPageState extends State<ShareHandlerPage> { } } - // SUCCESS: Navigate to ShareToMoodboardPage - if (finalFiles.isNotEmpty) { + // SUCCESS: Persist and Navigate + if (tempFiles.isNotEmpty) { + List<File> permanentFiles = []; + + // PERSIST IMMEDIATELY + // Save to App Docs and DB (into Inbox) and status 'completed' (waiting for tags) + for (var file in tempFiles) { + final id = await _imageService.saveImage( + file, + 0, // Project 0 = Inbox + tags: [], + ); + + final savedImage = await _imageService.getImage(id); + if (savedImage != null) { + permanentFiles.add(File(savedImage.filePath)); + } + } + if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (_) => ShareToMoodboardPage(imageFiles: finalFiles), + builder: (_) => ShareToMoodboardPage(imageFiles: permanentFiles), ), ); }