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