creek

The AI Image Editor of 2030
commit eae4ae8cd3cb542a29f583e3478f063bb241ec6f
parent 9be41dbed1afb9fa5a35a90c27d85e2f4763080f
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sun, 30 Nov 2025 20:08:37 +0530

Add Image Note (cropped area) Analysis

Also fix bottom bar safe area

Diffstat:
Mlib/data/database.dart | 5++++-
Mlib/data/models/note_model.dart | 12++++++++++++
Mlib/data/repos/note_repo.dart | 30+++++++++++++++++++++++++++---
Mlib/services/analysis_queue_manager.dart | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mlib/services/image_service.dart | 2+-
Mlib/services/note_service.dart | 7+++++--
Mlib/ui/pages/define_brand_page.dart | 2+-
Mlib/ui/pages/stylesheet_page.dart | 22+++++++++++++++++-----
Mlib/ui/widgets/bottom_bar.dart | 59+++++++++++++++++++++++++++++++++--------------------------
9 files changed, 262 insertions(+), 80 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_v5.db'; + static const String _dbName = 'database_v6.db'; static Future<Database> get db async { if (_db != null) return _db!; @@ -92,6 +92,9 @@ class AppDatabase { norm_y REAL DEFAULT 0.5, norm_width REAL DEFAULT 0.0, norm_height REAL DEFAULT 0.0, + analysis_data TEXT, + crop_file_path TEXT, + status TEXT DEFAULT 'pending', FOREIGN KEY (image_id) REFERENCES images (id) ON DELETE CASCADE ) '''); diff --git a/lib/data/models/note_model.dart b/lib/data/models/note_model.dart @@ -8,6 +8,9 @@ class NoteModel { final double normY; final double normWidth; final double normHeight; + final String? analysisData; + final String? cropFilePath; + final String status; NoteModel({ this.id, @@ -19,6 +22,9 @@ class NoteModel { this.normY = 0.5, this.normWidth = 0.0, this.normHeight = 0.0, + this.analysisData, + this.cropFilePath, + this.status = 'pending', }); Map<String, dynamic> toMap() { @@ -32,6 +38,9 @@ class NoteModel { 'norm_y': normY, 'norm_width': normWidth, 'norm_height': normHeight, + 'analysis_data': analysisData, + 'crop_file_path': cropFilePath, + 'status': status, }; } @@ -46,6 +55,9 @@ class NoteModel { normY: map['norm_y'] ?? 0.5, normWidth: map['norm_width'] ?? 0.0, normHeight: map['norm_height'] ?? 0.0, + analysisData: map['analysis_data'], + cropFilePath: map['crop_file_path'], + status: map['status'] ?? 'pending', ); } } diff --git a/lib/data/repos/note_repo.dart b/lib/data/repos/note_repo.dart @@ -21,8 +21,26 @@ class NoteRepo { return res.map((e) => NoteModel.fromMap(e)).toList(); } - // --- FIX IS HERE --- - // Added normX, normY, normWidth, normHeight as optional named parameters + Future<List<NoteModel>> getPendingNotes() async { + final db = await AppDatabase.db; + final res = await db.query( + 'notes', + where: 'status = ?', + whereArgs: ['pending'], + ); + return res.map((e) => NoteModel.fromMap(e)).toList(); + } + + Future<List<NoteModel>> getNotesByProjectId(int projectId) async { + final db = await AppDatabase.db; + final res = await db.rawQuery(''' + SELECT notes.* FROM notes + INNER JOIN images ON notes.image_id = images.id + WHERE images.project_id = ? + ''', [projectId]); + return res.map((e) => NoteModel.fromMap(e)).toList(); + } + Future<void> updateNote( int id, { String? content, @@ -31,6 +49,9 @@ class NoteRepo { double? normY, double? normWidth, double? normHeight, + String? analysisData, + String? status, + String? cropFilePath, }) async { final db = await AppDatabase.db; final Map<String, dynamic> updates = {}; @@ -38,12 +59,15 @@ class NoteRepo { if (content != null) updates['content'] = content; if (category != null) updates['category'] = category; - // Now these variables exist! if (normX != null) updates['norm_x'] = normX; if (normY != null) updates['norm_y'] = normY; if (normWidth != null) updates['norm_width'] = normWidth; if (normHeight != null) updates['norm_height'] = normHeight; + if (analysisData != null) updates['analysis_data'] = analysisData; + if (cropFilePath != null) updates['crop_file_path'] = cropFilePath; + if (status != null) updates['status'] = status; + if (updates.isNotEmpty) { await db.update('notes', updates, where: 'id = ?', whereArgs: [id]); } diff --git a/lib/services/analysis_queue_manager.dart b/lib/services/analysis_queue_manager.dart @@ -1,7 +1,12 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:image/image.dart' as img; import '../data/models/image_model.dart'; +import '../data/models/note_model.dart'; import '../data/repos/image_repo.dart'; +import '../data/repos/note_repo.dart'; import 'analyze/image_analyzer.dart'; class AnalysisQueueManager { @@ -11,56 +16,39 @@ class AnalysisQueueManager { AnalysisQueueManager._internal(); bool _isProcessing = false; - final _repo = ImageRepo(); + final _imageRepo = ImageRepo(); + final _noteRepo = NoteRepo(); + 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; + // 1. Fetch pending images from DB + List<ImageModel> pendingImages = await _imageRepo.getPendingImages(); + if (pendingImages.isNotEmpty) { + debugPrint("[Queue]: Found ${pendingImages.length} pending images"); + for (final image in pendingImages) { + await _processSingleImage(image); + } } - 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, - ); - } + // 2. Fetch pending notes from DB + List<NoteModel> pendingNotes = await _noteRepo.getPendingNotes(); + if (pendingNotes.isNotEmpty) { + debugPrint("[Queue]: Found ${pendingNotes.length} pending notes"); - // 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']}"); + // Group notes to avoid decoding parent image multiple times + final Map<String, List<NoteModel>> notesByImage = {}; + for (var note in pendingNotes) { + if (!notesByImage.containsKey(note.imageId)) { + notesByImage[note.imageId] = []; } - } catch (e) { - debugPrint("[Queue]: Analysis Exception: $e"); - await _repo.updateStatus(image.id, 'failed'); + notesByImage[note.imageId]!.add(note); + } + + for (final entry in notesByImage.entries) { + await _processNoteGroup(entry.key, entry.value); } } } catch (e) { @@ -69,4 +57,137 @@ class AnalysisQueueManager { _isProcessing = false; } } + + Future<void> _processSingleImage(ImageModel image) async { + await _imageRepo.updateStatus(image.id, 'analyzing'); + try { + Map<String, dynamic> result; + + // 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, + ); + } + + // Handle Result + if (result['success'] == true && result['data'] != null) { + final resultsMap = result['data']['results'] ?? {}; + final jsonString = jsonEncode(resultsMap); + await _imageRepo.updateAnalysis(image.id, jsonString); + await _imageRepo.updateStatus(image.id, 'completed'); + debugPrint("[Queue]: Completed: ${image.name}"); + } else { + await _imageRepo.updateStatus(image.id, 'failed'); + debugPrint("[Queue]: Failed: ${result['error']}"); + } + } catch (e) { + debugPrint("[Queue]: Analysis Exception: $e"); + await _imageRepo.updateStatus(image.id, 'failed'); + } + } + + Future<void> _processNoteGroup(String imageId, List<NoteModel> notes) async { + img.Image? parentImageCache; + try { + // 1. Fetch Parent Info + final parentImageModel = await _imageRepo.getById(imageId); + if (parentImageModel == null) throw Exception("Parent image not found: $imageId"); + final parentFile = File(parentImageModel.filePath); + if (!parentFile.existsSync()) throw Exception("Parent file missing"); + + // 2. Decode once for all notes in this group + final bytes = await parentFile.readAsBytes(); + parentImageCache = img.decodeImage(bytes); + if (parentImageCache == null) throw Exception("Failed to decode parent image"); + + final appDir = await getApplicationDocumentsDirectory(); + final cropDir = Directory('${appDir.path}/crops'); + if (!await cropDir.exists()) { + await cropDir.create(recursive: true); + } + + for (final note in notes) { + await _processSingleNoteWithCache(note, parentImageCache, cropDir); + } + } catch (e) { + debugPrint("[Queue]: Error processing group for image $imageId: $e"); + for (var note in notes) { + await _noteRepo.updateNote(note.id!, status: 'failed'); + } + } + } + + Future<void> _processSingleNoteWithCache( + NoteModel note, + img.Image parentImage, + Directory cropDir + ) async { + await _noteRepo.updateNote(note.id!, status: 'analyzing'); + try { + // 1. Define Permanent Path + final String cropPath = '${cropDir.path}/note_crop_${note.id}.jpg'; + final File cropFile = File(cropPath); + + // 2. Generate Crop (if it doesn't already exist) + if (!await cropFile.exists()) { + int x = (note.normX * parentImage.width).round(); + int y = (note.normY * parentImage.height).round(); + int w = (note.normWidth * parentImage.width).round(); + int h = (note.normHeight * parentImage.height).round(); + + // Center -> Top-Left + int left = x - (w ~/ 2); + int top = y - (h ~/ 2); + + // Clamping logic + if (left < 0) left = 0; + if (top < 0) top = 0; + if (left + w > parentImage.width) w = parentImage.width - left; + if (top + h > parentImage.height) h = parentImage.height - top; + + if (w <= 0 || h <= 0) { + debugPrint("[Queue]: Note ${note.id} has invalid dimensions"); + await _noteRepo.updateNote(note.id!, status: 'failed'); + return; + } + + final croppedImg = img.copyCrop(parentImage, x: left, y: top, width: w, height: h); + await cropFile.writeAsBytes(img.encodeJpg(croppedImg)); + } + + // 3. Analysis + debugPrint("[Queue]: Analyzing Note ${note.id} tag: ${note.category}"); + final result = await ImageAnalyzerService.analyzeSelected( + cropPath, + [note.category], + ); + + // 4. Update DB + if (result['success'] == true && result['data'] != null) { + final resultsMap = result['data']['results'] ?? {}; + final jsonString = jsonEncode(resultsMap); + await _noteRepo.updateNote( + note.id!, + analysisData: jsonString, + status: 'completed', + cropFilePath: cropPath, + ); + } else { + await _noteRepo.updateNote(note.id!, status: 'failed'); + } + } catch (e) { + debugPrint("[Queue]: Note processing error: $e"); + await _noteRepo.updateNote(note.id!, status: 'failed'); + } + } } diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart @@ -70,7 +70,7 @@ class ImageService { ); await _repo.addImage(image); - // 4. Trigger Analysis Queue (Always) + // 4. Trigger Analysis Queue AnalysisQueueManager().processQueue(); return id; diff --git a/lib/services/note_service.dart b/lib/services/note_service.dart @@ -1,5 +1,6 @@ import '../data/repos/note_repo.dart'; import '../data/models/note_model.dart'; +import 'analysis_queue_manager.dart'; class NoteService { final _repo = NoteRepo(); @@ -23,10 +24,11 @@ class NoteService { normWidth: normWidth, normHeight: normHeight, ); + await _repo.addNote(note); + AnalysisQueueManager().processQueue(); } - // UPDATE THIS TO MATCH REPO Future<void> updateNote( int noteId, { String? content, @@ -36,6 +38,8 @@ class NoteService { double? normWidth, double? normHeight, }) async { + // TODO + // If category or crop area changes, need to re-analyze await _repo.updateNote( noteId, content: content, @@ -51,7 +55,6 @@ class NoteService { await _repo.deleteNote(noteId); } - // Add this method to fetch notes for a specific image Future<List<NoteModel>> getNotesForImage(String imageId) async { return await _repo.getNotesForImage(imageId); } diff --git a/lib/ui/pages/define_brand_page.dart b/lib/ui/pages/define_brand_page.dart @@ -1,4 +1,4 @@ -git import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:adobe/ui/styles/variables.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../services/project_service.dart'; diff --git a/lib/ui/pages/stylesheet_page.dart b/lib/ui/pages/stylesheet_page.dart @@ -7,6 +7,7 @@ import 'package:adobe/ui/widgets/top_bar.dart'; import 'package:adobe/data/repos/image_repo.dart'; import 'package:adobe/data/repos/project_repo.dart'; import 'package:adobe/services/python_service.dart'; +import 'package:adobe/data/repos/note_repo.dart'; class StylesheetPage extends StatefulWidget { final int projectId; @@ -121,24 +122,36 @@ class _StylesheetPageState extends State<StylesheetPage> { }); try { + // 1. Fetch Image Analysis Data final images = await ImageRepo().getImages(_currentProjectId); - final analysisData = images + final List<String> analysisData = images .map((img) => img.analysisData) .where((data) => data != null && data.isNotEmpty) .cast<String>() .toList(); + // 2. Fetch Note Analysis Data + final notes = await NoteRepo().getNotesByProjectId(_currentProjectId); + final List<String> noteAnalysisData = notes + .map((n) => n.analysisData) + .where((data) => data != null && data.isNotEmpty) + .cast<String>() + .toList(); + + // 3. Combine both + analysisData.addAll(noteAnalysisData); + if (analysisData.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("No analyzed images found.")), + const SnackBar(content: Text("No analyzed images or notes found.")), ); } return; } final result = await PythonService().generateStylesheet(analysisData); - + if (mounted && result != null) { final jsonString = jsonEncode(result); await ProjectRepo().updateStylesheet(_currentProjectId, jsonString); @@ -596,4 +609,4 @@ class _StylesheetPageState extends State<StylesheetPage> { if (label.contains('dark')) return const Color(0xFF1a1a1a); return Colors.grey.shade400; } -} -\ No newline at end of file +} diff --git a/lib/ui/widgets/bottom_bar.dart b/lib/ui/widgets/bottom_bar.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:adobe/ui/styles/variables.dart'; @@ -47,42 +48,48 @@ class BottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + // System Safe Area Padding + final double safeBottom = MediaQuery.of(context).padding.bottom; + final double effectiveBottomPadding = max(safeBottom, 24.0); + return Container( - height: 90, decoration: const BoxDecoration( color: Variables.background, border: Border( top: BorderSide(color: Variables.borderSubtle, width: 1), ), ), - padding: const EdgeInsets.only(bottom: 24, top: 12), - child: Row( - children: [ - Expanded( - child: _buildNavItem( - context, - BottomBarItem.moodboard, - "Moodboard", - "assets/icons/moodboard_icon.svg", + padding: EdgeInsets.only(bottom: effectiveBottomPadding, top: 12), + child: SizedBox( + height: 54, + child: Row( + children: [ + Expanded( + child: _buildNavItem( + context, + BottomBarItem.moodboard, + "Moodboard", + "assets/icons/moodboard_icon.svg", + ), ), - ), - Expanded( - child: _buildNavItem( - context, - BottomBarItem.stylesheet, - "Stylesheet", - "assets/icons/stylesheet_icon.svg", + Expanded( + child: _buildNavItem( + context, + BottomBarItem.stylesheet, + "Stylesheet", + "assets/icons/stylesheet_icon.svg", + ), ), - ), - Expanded( - child: _buildNavItem( - context, - BottomBarItem.files, - "Files", - "assets/icons/files_icon.svg", + Expanded( + child: _buildNavItem( + context, + BottomBarItem.files, + "Files", + "assets/icons/files_icon.svg", + ), ), - ), - ], + ], + ), ), ); }