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:
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),
),
);
}