creek

The AI Image Editor of 2030
commit 2287021ee109f8564d2f9036271a4ac2ac35f32c
parent 3bd9114473c592db6d4bae9acb51d082f6ad9426
Author: maydayv7 <maydayv7@gmail.com>
Date:   Fri, 28 Nov 2025 14:28:27 +0530

Merge remote-tracking branch 'origin/sanjeebani'

Diffstat:

Mlib/data/database.dart | 8++++++--
Mlib/data/models/note_model.dart | 16++++++++++++++++
Mlib/data/repos/image_repo.dart | 49++++++++++++++++++++++++++++++++++++++-----------
Mlib/data/repos/note_repo.dart | 22++++++++++++++++++++--
Mlib/services/image_service.dart | 86++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mlib/services/note_service.dart | 40+++++++++++++++++++++++++++++++++++++---
Mlib/ui/pages/image_details_page.dart | 1222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Alib/ui/pages/image_save_page.dart | 770+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/ui/pages/project_board_page.dart | 65++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mlib/ui/pages/share_to_moodboard_page.dart | 157++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpubspec.yaml | 2+-
11 files changed, 1989 insertions(+), 448 deletions(-)

diff --git a/lib/data/database.dart b/lib/data/database.dart @@ -75,9 +75,13 @@ class AppDatabase { CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_id TEXT NOT NULL, - content TEXT, - category TEXT, + content TEXT NOT NULL, + category TEXT NOT NULL, created_at TEXT, + norm_x REAL DEFAULT 0.5, + norm_y REAL DEFAULT 0.5, + norm_width REAL DEFAULT 0.0, + norm_height REAL DEFAULT 0.0, 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 @@ -4,6 +4,10 @@ class NoteModel { final String content; final String category; final DateTime createdAt; + final double normX; + final double normY; + final double normWidth; + final double normHeight; NoteModel({ this.id, @@ -11,6 +15,10 @@ class NoteModel { required this.content, required this.category, required this.createdAt, + this.normX = 0.5, + this.normY = 0.5, + this.normWidth = 0.0, + this.normHeight = 0.0 }); Map<String, dynamic> toMap() { @@ -20,6 +28,10 @@ class NoteModel { 'content': content, 'category': category, 'created_at': createdAt.toIso8601String(), + 'norm_x': normX, + 'norm_y': normY, + 'norm_width': normWidth, + 'norm_height': normHeight, }; } @@ -30,6 +42,10 @@ class NoteModel { content: map['content'], category: map['category'], createdAt: DateTime.parse(map['created_at']), + normX: map['norm_x'] ?? 0.5, + normY: map['norm_y'] ?? 0.5, + normWidth: map['norm_width'] ?? 0.0, + normHeight: map['norm_height'] ?? 0.0, ); } } diff --git a/lib/data/repos/image_repo.dart b/lib/data/repos/image_repo.dart @@ -3,7 +3,6 @@ import '../database.dart'; import '../models/image_model.dart'; class ImageRepo { - Future<void> addImage(ImageModel image) async { final db = await AppDatabase.db; await db.insert('images', image.toMap()); @@ -27,23 +26,49 @@ class ImageRepo { return null; } + /// NEW METHOD: Fetches tags for the specific image + Future<List<String>> getTagsForImage(dynamic id) async { + final db = await AppDatabase.db; + final res = await db.query( + 'images', + columns: ['tags'], + where: 'id = ?', + whereArgs: [id], + ); + + if (res.isNotEmpty && res.first['tags'] != null) { + try { + // Decode the JSON string stored in the DB back to a List<String> + final tagsJson = res.first['tags'] as String; + if (tagsJson.isEmpty) return []; + + final List<dynamic> decoded = jsonDecode(tagsJson); + return decoded.map((e) => e.toString()).toList(); + } catch (e) { + print("Error decoding tags: $e"); + return []; + } + } + return []; + } + Future<void> updateAnalysis(String id, String analysisData) async { final db = await AppDatabase.db; await db.update( - 'images', - {'analysis_data': analysisData}, - where: 'id = ?', - whereArgs: [id] + 'images', + {'analysis_data': analysisData}, + where: 'id = ?', + whereArgs: [id], ); } Future<void> updateTags(String id, List<String> tags) async { final db = await AppDatabase.db; await db.update( - 'images', - {'tags': jsonEncode(tags)}, - where: 'id = ?', - whereArgs: [id] + 'images', + {'tags': jsonEncode(tags)}, + where: 'id = ?', + whereArgs: [id], ); } @@ -53,12 +78,14 @@ class ImageRepo { } /// Helper for Project Deletion Service - Future<List<String>> getAllFilePathsForProjectIds(List<int> projectIds) async { + Future<List<String>> getAllFilePathsForProjectIds( + List<int> projectIds, + ) async { if (projectIds.isEmpty) return []; final db = await AppDatabase.db; final idList = projectIds.join(','); final res = await db.rawQuery( - 'SELECT file_path FROM images WHERE project_id IN ($idList)' + 'SELECT file_path FROM images WHERE project_id IN ($idList)', ); return res.map((e) => e['file_path'] as String).toList(); } diff --git a/lib/data/repos/note_repo.dart b/lib/data/repos/note_repo.dart @@ -9,7 +9,7 @@ class NoteRepo { return await db.insert('notes', note.toMap()); } - Future<List<NoteModel>> getNotes(String imageId) async { + Future<List<NoteModel>> getNotesForImage(dynamic imageId) async { final db = await AppDatabase.db; final res = await db.query( 'notes', @@ -17,15 +17,33 @@ class NoteRepo { whereArgs: [imageId], orderBy: 'created_at DESC', ); + return res.map((e) => NoteModel.fromMap(e)).toList(); } - Future<void> updateNote(int id, {String? content, String? category}) async { + // --- FIX IS HERE --- + // Added normX, normY, normWidth, normHeight as optional named parameters + Future<void> updateNote( + int id, { + String? content, + String? category, + double? normX, + double? normY, + double? normWidth, + double? normHeight, + }) async { final db = await AppDatabase.db; final Map<String, dynamic> updates = {}; + 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 (updates.isNotEmpty) { await db.update('notes', updates, where: 'id = ?', whereArgs: [id]); } diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart @@ -11,45 +11,54 @@ class ImageService { final ImageRepo _repo = ImageRepo(); final Uuid _uuid = const Uuid(); - Future<void> saveImage(File file, int projectId) async { - await saveImages([file], projectId); - } - - Future<void> saveImages(List<File> files, int projectId) async { + /// Saves a single image and returns its ID. + /// This return type (Future<String>) is REQUIRED for ImageSavePage. + Future<String> saveImage(File file, int projectId) async { // 1. Prepare Directory final dir = await getApplicationDocumentsDirectory(); final folder = Directory("${dir.path}/images"); if (!await folder.exists()) await folder.create(recursive: true); + // 2. Generate ID and Path + final String id = _uuid.v4(); + String ext = "jpg"; // Default + try { + if (file.path.contains('.')) { + ext = file.path.split('.').last; + } + } catch (_) {} + + final String newPath = "${folder.path}/$id.$ext"; + await file.copy(newPath); + + // 3. Create & Save Model + final image = ImageModel( + id: id, + projectId: projectId, + filePath: newPath, + name: file.path.split('/').last, + createdAt: DateTime.now(), + tags: [], + ); + + await _repo.addImage(image); + + // 4. Run Analysis in Background + _analyzeInBackground(id, newPath); + + // 5. RETURN THE ID (Critical fix) + return id; + } + + /// Bulk save method (optional, but good helper) + Future<List<String>> saveImages(List<File> files, int projectId) async { + List<String> ids = []; for (var file in files) { - final String id = _uuid.v4(); - - // 2. Determine Extension & Copy File to Permanent Storage - String ext = "jpg"; // Default - try { - if (file.path.contains('.')) { - ext = file.path.split('.').last; - } - } catch (_) {} - - final String newPath = "${folder.path}/$id.$ext"; - await file.copy(newPath); - - // 3. Create & Save Model - final image = ImageModel( - id: id, - projectId: projectId, - filePath: newPath, - name: file.path.split('/').last, - createdAt: DateTime.now(), - tags: [], - ); - - await _repo.addImage(image); - - // 4. Run Analysis in Background - _analyzeInBackground(id, newPath); + // Reuse the single save logic to avoid code duplication + String id = await saveImage(file, projectId); + ids.add(id); } + return ids; } Future<void> _analyzeInBackground(String imageId, String filePath) async { @@ -74,8 +83,17 @@ class ImageService { await _repo.updateAnalysis(id, jsonString); } - Future<void> updateTags(String id, List<String> tags) async { - await _repo.updateTags(id, tags); + Future<ImageModel?> getImage(String id) async { + return await _repo.getById(id); + } + + Future<List<String>> getTags(String imageId) async { + return await _repo.getTagsForImage(imageId); + } + + + Future<void> updateTags(String imageId, List<String> newTags) async { + await _repo.updateTags(imageId, newTags); } Future<void> deleteImage(String id) async { diff --git a/lib/services/note_service.dart b/lib/services/note_service.dart @@ -4,21 +4,55 @@ import '../data/models/note_model.dart'; class NoteService { final _repo = NoteRepo(); - Future<void> addNote(String imageId, String content, String category) async { + Future<void> addNote( + String imageId, + String content, + String category, { + double normX = 0.5, + double normY = 0.5, + double normWidth = 0.0, + double normHeight = 0.0, + }) async { final note = NoteModel( imageId: imageId, content: content, category: category, createdAt: DateTime.now(), + normX: normX, + normY: normY, + normWidth: normWidth, + normHeight: normHeight, ); await _repo.addNote(note); } - Future<void> updateNote(int noteId, {String? content, String? category}) async { - await _repo.updateNote(noteId, content: content, category: category); + // UPDATE THIS TO MATCH REPO + Future<void> updateNote( + int noteId, { + String? content, + String? category, + double? normX, + double? normY, + double? normWidth, + double? normHeight, + }) async { + await _repo.updateNote( + noteId, + content: content, + category: category, + normX: normX, + normY: normY, + normWidth: normWidth, + normHeight: normHeight, + ); } Future<void> deleteNote(int noteId) async { 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/image_details_page.dart b/lib/ui/pages/image_details_page.dart @@ -1,33 +1,61 @@ -// BROKEN - import 'dart:io'; +import 'dart:ui' as ui; // Fixed import import 'package:flutter/material.dart'; + +// --- IMPORTS --- +import '../../services/image_service.dart'; +import '../../services/note_service.dart'; +import '../../data/models/note_model.dart'; import '../../data/models/image_model.dart'; -import '../../data/models/comment_model.dart'; -import '../../data/repos/image_repo.dart'; -import '../../data/repos/comment_repo.dart'; -import 'package:intl/intl.dart'; -class ImageDetailPage extends StatefulWidget { +class ImageDetailsPage extends StatefulWidget { + final String imagePath; final String imageId; + final int projectId; - const ImageDetailPage({super.key, required this.imageId}); + const ImageDetailsPage({ + Key? key, + required this.imagePath, + required this.imageId, + required this.projectId, + }) : super(key: key); @override - State<ImageDetailPage> createState() => _ImageDetailPageState(); + State<ImageDetailsPage> createState() => _ImageDetailsPageState(); } -class _ImageDetailPageState extends State<ImageDetailPage> { - final _imageRepo = ImageRepository(); - final _commentRepo = CommentRepository(); - - ImageModel? _image; - List<Comment> _comments = []; +class _ImageDetailsPageState extends State<ImageDetailsPage> { + // Services + final NoteService _noteService = NoteService(); + final ImageService _imageService = ImageService(); + + // State + ImageModel? _imageModel; + List<NoteModel> _notes = []; + List<String> _currentTags = []; bool _isLoading = true; - - // Input controllers - final _commentController = TextEditingController(); - CommentType _selectedType = CommentType.layout; // Default type + int? _activeNoteId; + + // -- DRAWING STATE -- + bool _isDrawMode = false; + final GlobalKey _imageKey = GlobalKey(); + + Offset? _startPos; + Offset? _currentPos; + Rect? _finalSelectionRect; + Size? _imageRenderSize; + + // Master List of Tags + final List<String> _allAvailableTags = [ + 'Fonts', + 'Colours', + 'Everything!', + 'Compositions', + 'Textures', + 'Layout', + 'Dark Mode', + 'Minimal', + ]; @override void initState() { @@ -36,350 +64,933 @@ class _ImageDetailPageState extends State<ImageDetailPage> { } Future<void> _loadData() async { - setState(() => _isLoading = true); try { - // 1. Fetch detailed image info (including comments if repo supports it, - // or fetch separately if not fully integrated yet) - final data = null; // await _imageRepo.getImageDetails(widget.imageId); - - if (data != null) { - // Parse Image - _image = ImageModel.fromMap(data); - - // Parse Comments from the joined data - if (data['comments'] != null) { - final commentsList = data['comments'] as List; - _comments = commentsList.map((c) => Comment.fromMap(c)).toList(); - } + final image = await _imageService.getImage(widget.imageId); + final notes = await _noteService.getNotesForImage(widget.imageId); + final tags = await _imageService.getTags(widget.imageId); + + if (mounted) { + setState(() { + _imageModel = image; + _notes = notes; + _currentTags = tags; + _isLoading = false; + }); } } catch (e) { debugPrint("Error loading details: $e"); - } finally { if (mounted) setState(() => _isLoading = false); } } - Future<void> _addComment() async { - if (_commentController.text.trim().isEmpty || _image == null) return; - - final newComment = Comment( - imageId: _image!.id, - content: _commentController.text.trim(), - type: _selectedType, - createdAt: DateTime.now().toIso8601String(), - ); + // --- ACTIONS --- - try { - await _commentRepo.addComment(newComment); - _commentController.clear(); - // Refresh data to show new comment - await _loadData(); - } catch (e) { - debugPrint("Error adding comment: $e"); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Failed to add comment: $e")), - ); - } - } + Future<void> _removeTag(String tag) async { + setState(() { + _currentTags.remove(tag); + }); + await _imageService.updateTags(widget.imageId, _currentTags); } - @override - Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold( - backgroundColor: Colors.white, - body: Center(child: CircularProgressIndicator()), - ); - } + void _activateDrawMode() { + setState(() { + _isDrawMode = true; + _finalSelectionRect = null; + _startPos = null; + _currentPos = null; + }); + } - if (_image == null) { - return Scaffold( - appBar: AppBar(), - body: const Center(child: Text("Image not found")), - ); - } + void _openNotesSheet({int? highlightId}) { + setState(() => _activeNoteId = highlightId); - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.black), - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: const Icon(Icons.share_outlined, color: Colors.black), - onPressed: () { - // Share logic + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: + (context) => _NotesListSheet( + notes: _notes, + highlightId: highlightId, + onAddNotePressed: () { + Navigator.pop(context); + _activateDrawMode(); }, ), - IconButton( - icon: const Icon(Icons.more_horiz, color: Colors.black), - onPressed: () {}, - ), - ], - ), - body: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. IMAGE DISPLAY - Center( - child: Container( - constraints: const BoxConstraints(maxHeight: 500), - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ) - ], + ).whenComplete(() { + setState(() => _activeNoteId = null); + }); + } + + // --- DRAWING GESTURES --- + + void _onPanStart(DragStartDetails details) { + if (!_isDrawMode) return; + setState(() { + _startPos = details.localPosition; + _currentPos = details.localPosition; + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + if (!_isDrawMode) return; + setState(() { + _currentPos = details.localPosition; + }); + } + + void _onPanEnd(DragEndDetails details) { + if (!_isDrawMode || _startPos == null || _currentPos == null) return; + + final rect = Rect.fromPoints(_startPos!, _currentPos!); + final RenderBox? box = + _imageKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null) { + _imageRenderSize = box.size; + } + + _finalSelectionRect = rect; + + setState(() { + _isDrawMode = false; + _startPos = null; + _currentPos = null; + }); + + _showAddNoteInputDialog(); + } + + // --- ADD NOTE INPUT DIALOG --- + void _showAddNoteInputDialog() { + final TextEditingController newNoteController = TextEditingController(); + String newCategory = 'Typography'; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: + (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Add Note", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Image.file( - File(_image!.filePath), - fit: BoxFit.contain, - errorBuilder: (c, e, s) => Container( - height: 300, - color: Colors.grey[200], - child: const Icon(Icons.broken_image, size: 50, color: Colors.grey), - ), + ), + const SizedBox(height: 16), + + DropdownButtonFormField<String>( + value: newCategory, + decoration: InputDecoration( + labelText: "Category", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), ), + items: + [ + 'Typography', + 'Color Palette', + 'Layout', + 'Design Style', + 'General', + ] + .map( + (c) => + DropdownMenuItem(value: c, child: Text(c)), + ) + .toList(), + onChanged: (v) => newCategory = v!, ), - ), - const SizedBox(height: 16), - - // 2. INFO (Name & Link) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _image!.name ?? "Untitled Image", - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - fontFamily: 'GeneralSans', - ), - ), - const SizedBox(height: 4), - Text( - "https://www.adobe.com/project/...", // Placeholder or real link if you have it - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - decoration: TextDecoration.underline, - fontFamily: 'GeneralSans', - ), + const SizedBox(height: 12), + + TextField( + controller: newNoteController, + autofocus: true, + maxLines: 3, + decoration: InputDecoration( + hintText: "Enter note details...", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - ], + ), ), - ), - const SizedBox(height: 24), - - // 3. COMMENTS SECTION - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - const Text( - "Comments", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - fontFamily: 'GeneralSans', + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), ), - const Spacer(), - // Analysis / Tag icons - const Icon(Icons.local_offer_outlined, size: 20, color: Colors.black), - const SizedBox(width: 12), - const Icon(Icons.fullscreen, size: 24, color: Colors.black), - ], - ), - ), + onPressed: () async { + if (newNoteController.text.isNotEmpty && + _finalSelectionRect != null && + _imageRenderSize != null) { + final nX = + _finalSelectionRect!.center.dx / + _imageRenderSize!.width; + final nY = + _finalSelectionRect!.center.dy / + _imageRenderSize!.height; + final nW = + _finalSelectionRect!.width / + _imageRenderSize!.width; + final nH = + _finalSelectionRect!.height / + _imageRenderSize!.height; + + await _noteService.addNote( + widget.imageId, + newNoteController.text.trim(), + newCategory, + normX: nX, + normY: nY, + normWidth: nW, + normHeight: nH, + ); - const SizedBox(height: 12), + final updatedNotes = await _noteService + .getNotesForImage(widget.imageId); + setState(() { + _notes = updatedNotes; + }); - if (_comments.isEmpty) - const Padding( - padding: EdgeInsets.all(20.0), - child: Center( - child: Text( - "No comments yet. Add one below!", - style: TextStyle(color: Colors.grey), + Navigator.pop(context); + } + }, + child: const Text( + "Save Note", + style: TextStyle(fontWeight: FontWeight.bold), ), ), - ) - else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: _comments.length, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (context, index) { - return _buildCommentItem(_comments[index]); - }, ), - - const SizedBox(height: 100), // Space for bottom sheet - ], + ], + ), ), ), ), - ], - ), - // 4. ADD COMMENT INPUT - bottomSheet: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, -2), - ), - ], - ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - // Type Selector (Dropdown or similar) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton<CommentType>( - value: _selectedType, - icon: const Icon(Icons.arrow_drop_down), - items: CommentType.values.map((type) { - return DropdownMenuItem( - value: type, - child: Text( - type.name.toUpperCase(), - style: const TextStyle(fontSize: 12, fontFamily: 'GeneralSans'), + ); + } + + // --- EDIT TAGS DIALOG --- + void _openEditTagsDialog() { + showDialog( + context: context, + builder: (ctx) { + List<String> tempTags = List.from(_currentTags); + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text( + "Edit Tags", + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: + _allAvailableTags.map((tag) { + final isSelected = tempTags.contains(tag); + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) + tempTags.remove(tag); + else + tempTags.add(tag); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - ); - }).toList(), - onChanged: (val) { - if (val != null) setState(() => _selectedType = val); - }, - ), + decoration: BoxDecoration( + color: + isSelected + ? const Color(0xFFEEF0FF) + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.grey[300]!, + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + const Icon( + Icons.close, + size: 14, + color: Color(0xFF7C4DFF), + ), + const SizedBox(width: 4), + ], + Text( + tag, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.black87, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text( + "Cancel", + style: TextStyle(color: Colors.grey), + ), + ), + TextButton( + onPressed: () async { + this.setState(() => _currentTags = tempTags); + await _imageService.updateTags(widget.imageId, tempTags); + Navigator.pop(context); + }, + child: const Text( + "Save", + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, ), ), - const SizedBox(width: 12), - // Text Input + ), + ], + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + size: 20, + color: Colors.black, + ), + onPressed: () => Navigator.pop(context), + ), + ), + + body: + _isLoading + ? const Center( + child: CircularProgressIndicator(color: Colors.black), + ) + : Column( + children: [ Expanded( - child: TextField( - controller: _commentController, - decoration: const InputDecoration( - hintText: "Add a comment...", - border: InputBorder.none, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- 1. IMAGE CONTAINER --- + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + constraints: const BoxConstraints(maxHeight: 500), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.passthrough, + children: [ + InteractiveViewer( + panEnabled: !_isDrawMode, + scaleEnabled: !_isDrawMode, + child: GestureDetector( + onPanStart: + _isDrawMode ? _onPanStart : null, + onPanUpdate: + _isDrawMode ? _onPanUpdate : null, + onPanEnd: + _isDrawMode ? _onPanEnd : null, + child: Stack( + children: [ + Image.file( + File(widget.imagePath), + key: _imageKey, + fit: BoxFit.contain, + width: double.infinity, + ), + + if (_isDrawMode && + _startPos != null && + _currentPos != null) + Positioned.fill( + child: CustomPaint( + painter: + SelectionOverlayPainter( + rect: Rect.fromPoints( + _startPos!, + _currentPos!, + ), + ), + ), + ), + ], + ), + ), + ), + + ..._notes.map((note) { + final x = + note.normX * constraints.maxWidth; + final y = + note.normY * constraints.maxHeight; + final isActive = + note.id == _activeNoteId; + + return Positioned( + left: x - 10, + top: y - 10, + child: GestureDetector( + onTap: + () => _openNotesSheet( + highlightId: note.id, + ), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black + .withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + border: + isActive + ? Border.all( + color: const Color( + 0xFF7C4DFF, + ), + width: 3, + ) + : null, + ), + ), + ), + ); + }).toList(), + + // Notes Button + Positioned( + bottom: 12, + right: 12, + child: ElevatedButton( + onPressed: () => _openNotesSheet(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 4, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + "Notes", + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + SizedBox(width: 8), + Icon( + Icons.assignment_outlined, + size: 18, + ), + ], + ), + ), + ), + + if (_isDrawMode && _startPos == null) + Positioned( + top: 20, + left: 0, + right: 0, + child: Center( + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: + BorderRadius.circular(20), + ), + child: const Text( + "Drag to add note", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + + const SizedBox(height: 20), + + // --- 2. INFO SECTION --- + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _imageModel?.name ?? "Untitled Image", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.black87, + ), + ), + + const SizedBox(height: 24), + + // Tags Box + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey[200]!, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Tags", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + GestureDetector( + onTap: _openEditTagsDialog, + child: const Icon( + Icons.edit_outlined, + size: 18, + color: Colors.black54, + ), + ), + ], + ), + const SizedBox(height: 12), + Divider( + height: 1, + color: Colors.grey[300], + ), + const SizedBox(height: 16), + + // Main View: Only show selected tags + SizedBox( + width: double.infinity, + child: + _currentTags.isEmpty + ? const Text( + "No tags yet.", + style: TextStyle( + color: Colors.grey, + ), + ) + : Wrap( + spacing: 8, + runSpacing: 8, + children: + _currentTags.map((tag) { + // Style as "Selected" (Purple with X) + return GestureDetector( + onTap: + () => _removeTag( + tag, + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: + 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: + const Color( + 0xFFEEF0FF, + ), + borderRadius: + BorderRadius.circular( + 20, + ), + border: Border.all( + color: + const Color( + 0xFF7C4DFF, + ), + width: 1, + ), + ), + child: Row( + mainAxisSize: + MainAxisSize + .min, + children: [ + Text( + tag, + style: const TextStyle( + fontSize: + 13, + fontWeight: + FontWeight + .w500, + color: Color( + 0xFF7C4DFF, + ), + ), + ), + const SizedBox( + width: 4, + ), + const Icon( + Icons.close, + size: 14, + color: Color( + 0xFF7C4DFF, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], ), ), ), - // Send Button - IconButton( - icon: const Icon(Icons.send, color: Colors.blue), - onPressed: _addComment, - ), ], ), - ], - ), - ), - ), ); } +} + +// --- NOTES LIST SHEET --- +class _NotesListSheet extends StatefulWidget { + final List<NoteModel> notes; + final int? highlightId; + final VoidCallback onAddNotePressed; + + const _NotesListSheet({ + Key? key, + required this.notes, + this.highlightId, + required this.onAddNotePressed, + }) : super(key: key); + + @override + State<_NotesListSheet> createState() => _NotesListSheetState(); +} + +class _NotesListSheetState extends State<_NotesListSheet> { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + if (widget.highlightId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final index = widget.notes.indexWhere( + (n) => n.id == widget.highlightId, + ); + if (index != -1 && _scrollController.hasClients) { + _scrollController.animateTo( + index * 80.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + } - Widget _buildCommentItem(Comment comment) { - final date = DateTime.tryParse(comment.createdAt); - final timeStr = date != null ? DateFormat.jm().format(date) : ""; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Vertical Line indicator - Container( - width: 4, - height: 40, - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(2), + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.85, + builder: (_, controller) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - ), - const SizedBox(width: 12), - Expanded( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Comment Type Tag (Pill) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(4), - ), - child: Text( - comment.type.name, // e.g. "Layout", "Colour" - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], - fontWeight: FontWeight.w500, - fontFamily: 'GeneralSans', - ), - ), - ), - const SizedBox(width: 8), - // Time Text( - timeStr, - style: TextStyle( - fontSize: 12, - color: Colors.grey[400], - fontFamily: 'GeneralSans', + "Notes (${widget.notes.length})", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, ), ), + IconButton( + icon: const Icon(Icons.close, color: Colors.grey), + onPressed: () => Navigator.pop(context), + ), ], ), - const SizedBox(height: 4), - Text( - comment.content, - style: const TextStyle( - fontSize: 14, - color: Colors.black87, - height: 1.4, - fontFamily: 'GeneralSans', + const Divider(), + + Expanded( + child: ListView.builder( + controller: controller, + itemCount: widget.notes.length, + itemBuilder: (context, index) { + final note = widget.notes[index]; + final isHighlighted = note.id == widget.highlightId; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + isHighlighted + ? const Color(0xFFF3F0FF) + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isHighlighted + ? const Color(0xFF7C4DFF) + : Colors.grey[200]!, + width: isHighlighted ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFEEF0FF), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + note.category, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF7C4DFF), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + note.content, + style: const TextStyle( + fontSize: 15, + color: Colors.black87, + height: 1.4, + ), + ), + ], + ), + ); + }, + ), + ), + + // --- ADD NOTE BUTTON (BOTTOM OF SHEET) --- + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + height: 50, + child: FilledButton.icon( + onPressed: widget.onAddNotePressed, + icon: const Icon(Icons.add), + label: const Text("Add Note"), + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ), ), + const SizedBox(height: 10), ], ), - ), - ], + ); + }, + ); + } +} + +class SelectionOverlayPainter extends CustomPainter { + final Rect rect; + SelectionOverlayPainter({required this.rect}); + + @override + void paint(Canvas canvas, Size size) { + final Path backgroundPath = + Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final Path holePath = Path()..addRect(rect); + final Path overlayPath = Path.combine( + ui.PathOperation.difference, + backgroundPath, + holePath, + ); + + canvas.drawPath(overlayPath, Paint()..color = Colors.black54); + + final Paint borderPaint = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + double dashWidth = 6; + double dashSpace = 4; + Path borderPath = Path()..addRect(rect); + for (ui.PathMetric pathMetric in borderPath.computeMetrics()) { + double distance = 0.0; + while (distance < pathMetric.length) { + canvas.drawPath( + pathMetric.extractPath(distance, distance + dashWidth), + borderPaint, + ); + distance += (dashWidth + dashSpace); + } + } + + final Paint dotPaint = + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle( + rect.center, + 8, + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), ); + canvas.drawCircle(rect.center, 6, dotPaint); } -} -\ No newline at end of file + + @override + bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) => + rect != oldDelegate.rect; +} diff --git a/lib/ui/pages/image_save_page.dart b/lib/ui/pages/image_save_page.dart @@ -0,0 +1,770 @@ +import 'dart:io'; +import 'dart:ui'; // Required for PathMetric +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +//services +import '../../services/image_service.dart'; +import '../../services/note_service.dart'; +import '../../services/analyze/image_analyzer.dart'; + +// --- HELPER CLASS FOR TEMPORARY NOTES --- +class TempNote { + final double normX; + final double normY; + final double normWidth; + final double normHeight; + final String content; + final String category; + + TempNote({ + required this.normX, + required this.normY, + required this.normWidth, + required this.normHeight, + required this.content, + required this.category, + }); +} + +class ImageSavePage extends StatefulWidget { + final List<String> imagePaths; + final int projectId; + final String projectName; + final bool isFromShare; + + const ImageSavePage({ + Key? key, + required this.imagePaths, + required this.projectId, + required this.projectName, + this.isFromShare = true, + }) : super(key: key); + + @override + State<ImageSavePage> createState() => _ImageSavePageState(); +} + +class _ImageSavePageState extends State<ImageSavePage> { + // --- SERVICES --- + final ImageService _imageService = ImageService(); + final NoteService _noteService = NoteService(); + + // --- STATE --- + int _currentImageIndex = 0; + final Set<String> _selectedTags = {}; + final TextEditingController _commentController = TextEditingController(); + + // List to store multiple notes before saving + final List<TempNote> _addedNotes = []; + + String _selectedCategory = 'Typography'; + bool _isSaving = false; + + // --- INTERACTION STATE --- + bool _isDrawMode = false; + final GlobalKey _imageKey = GlobalKey(); + + Offset? _startPos; + Offset? _currentPos; + Rect? _finalSelectionRect; // Current drawing + Size? _imageRenderSize; // Size of image on screen + + final List<String> _availableTags = [ + 'Fonts', + 'Colours', + 'Everything!', + 'Compositions', + 'Textures', + 'Layout', + 'Dark Mode', + 'Minimal', + ]; + + final List<String> _categories = [ + 'Typography', + 'Color Palette', + 'Layout', + 'Design Style', + 'General', + ]; + + // --- ACTIONS --- + + void _activateSelectionMode() { + setState(() { + _isDrawMode = true; + _finalSelectionRect = null; + _startPos = null; + _currentPos = null; + }); + } + + void _toggleTag(String tag) { + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags.remove(tag); + } else { + _selectedTags.add(tag); + } + }); + } + + // --- GESTURES --- + + void _onPanStart(DragStartDetails details) { + if (!_isDrawMode) return; + setState(() { + _startPos = details.localPosition; + _currentPos = details.localPosition; + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + if (!_isDrawMode) return; + setState(() { + _currentPos = details.localPosition; + }); + } + + void _onPanEnd(DragEndDetails details) { + if (!_isDrawMode || _startPos == null || _currentPos == null) return; + + final rect = Rect.fromPoints(_startPos!, _currentPos!); + final RenderBox? box = + _imageKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null) { + _imageRenderSize = box.size; + } + + _finalSelectionRect = rect; + + setState(() { + _isDrawMode = false; + _startPos = null; + _currentPos = null; + }); + + _showNoteModal(); + } + + // --- MODAL --- + void _showNoteModal() { + // Clear controller for new note + _commentController.clear(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + const CircleAvatar( + radius: 16, + backgroundColor: Colors.black12, + child: Icon(Icons.person, color: Colors.grey), + ), + const SizedBox(width: 10), + const Text( + "You", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const Spacer(), + + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8EAF6), + borderRadius: BorderRadius.circular(20), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton<String>( + value: _selectedCategory, + isDense: true, + icon: const Icon( + Icons.keyboard_arrow_down, + size: 18, + ), + style: const TextStyle( + color: Colors.black87, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + items: + _categories + .map( + (c) => DropdownMenuItem( + value: c, + child: Text(c), + ), + ) + .toList(), + onChanged: + (v) => setState(() => _selectedCategory = v!), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(30), + ), + child: TextField( + controller: _commentController, + autofocus: true, + style: const TextStyle(fontSize: 14), + decoration: const InputDecoration( + hintText: "Add your note...", + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + // ADD NOTE BUTTON + IconButton( + onPressed: () { + // 1. Add to Temp List + _addTempNote(); + Navigator.pop(context); + }, + icon: const Icon(Icons.check_circle, size: 30), + color: Colors.black, + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + // --- LOGIC TO ADD TEMP NOTE --- + void _addTempNote() { + if (_finalSelectionRect != null && + _imageRenderSize != null && + _commentController.text.isNotEmpty) { + // Calculate Normalized Coords + final nX = _finalSelectionRect!.center.dx / _imageRenderSize!.width; + final nY = _finalSelectionRect!.center.dy / _imageRenderSize!.height; + final nW = _finalSelectionRect!.width / _imageRenderSize!.width; + final nH = _finalSelectionRect!.height / _imageRenderSize!.height; + + final newNote = TempNote( + normX: nX, + normY: nY, + normWidth: nW, + normHeight: nH, + content: _commentController.text.trim(), + category: _selectedCategory, + ); + + setState(() { + _addedNotes.add(newNote); + }); + } + } + + // --- SAVE FINAL (ALL NOTES) --- + Future<void> _onSaveToMoodboard() async { + setState(() => _isSaving = true); + + try { + for (String path in widget.imagePaths) { + final file = File(path); + if (!file.existsSync()) continue; + + // 1. Save Image (Once) + final imageId = await _imageService.saveImage(file, widget.projectId); + + // 2. Save Tags + if (_selectedTags.isNotEmpty) { + await _imageService.updateTags(imageId, _selectedTags.toList()); + } + + // 3. Save ALL Added Notes + for (var note in _addedNotes) { + await _noteService.addNote( + imageId, + note.content, + note.category, + normX: note.normX, + normY: note.normY, + normWidth: note.normWidth, + normHeight: note.normHeight, + ); + } + + // 4. Analyze + _analyzeInBackground(imageId, file.path); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Saved to ${widget.projectName}!'), + backgroundColor: Colors.green, + ), + ); + if (widget.isFromShare) + SystemNavigator.pop(); + else + Navigator.popUntil(context, (route) => route.isFirst); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + setState(() => _isSaving = false); + } + } + } + + Future<void> _analyzeInBackground(String imageId, String originalPath) async { + try { + final result = await ImageAnalyzerService.analyzeFullSuite(originalPath); + await _imageService.updateAnalysis(imageId, result); + } catch (e) { + debugPrint("Analysis Error: $e"); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + size: 20, + color: Colors.black, + ), + onPressed: () => Navigator.pop(context), + ), + title: Text( + widget.projectName, + style: const TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: false, + titleSpacing: 0, + ), + body: Column( + children: [ + // --- IMAGE AREA --- + Expanded( + child: Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: LayoutBuilder( + // Needed to position dots accurately + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + InteractiveViewer( + panEnabled: !_isDrawMode, + scaleEnabled: !_isDrawMode, + child: Center( + child: GestureDetector( + onPanStart: _isDrawMode ? _onPanStart : null, + onPanUpdate: _isDrawMode ? _onPanUpdate : null, + onPanEnd: _isDrawMode ? _onPanEnd : null, + child: Stack( + children: [ + Image.file( + File(widget.imagePaths[_currentImageIndex]), + key: _imageKey, + fit: BoxFit.contain, + ), + + // 1. Current Drawing Overlay + if (_isDrawMode && + _startPos != null && + _currentPos != null) + Positioned.fill( + child: CustomPaint( + painter: SelectionOverlayPainter( + rect: Rect.fromPoints( + _startPos!, + _currentPos!, + ), + ), + ), + ), + ], + ), + ), + ), + ), + + // 2. RENDER PERSISTENT DOTS FOR ADDED NOTES + ..._addedNotes.map((note) { + return Positioned( + left: (note.normX * constraints.maxWidth) - 10, + top: (note.normY * constraints.maxHeight) - 10, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + // Removed borders, just pure white dots per request + ), + ), + ); + }).toList(), + + // 3. Notes Button + Positioned( + bottom: 12, // Moved closer to corner (was 16) + right: 12, // Moved closer to corner (was 16) + child: ElevatedButton( + onPressed: _activateSelectionMode, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 2, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Colors.grey[200]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text( + "Notes", + style: TextStyle(fontWeight: FontWeight.w600), + ), + SizedBox(width: 8), + Icon(Icons.assignment_outlined, size: 18), + ], + ), + ), + ), + + // 4. "TOP" INSTRUCTION POP UP + if (_isDrawMode && _startPos == null) + Positioned( + top: 20, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Text( + "Drag on image to select area", + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + + // --- BOTTOM FORM AREA --- + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Column( + children: [ + // --- THE BOX WRAPPER --- + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[300]!, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'What do you like about this image?', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + + const Divider(height: 1, color: Color(0xFFEEEEEE)), + + const SizedBox(height: 16), + + // Tags + Wrap( + spacing: 8, + runSpacing: 8, + children: + _availableTags.map((tag) { + final isSelected = _selectedTags.contains(tag); + return GestureDetector( + onTap: () => _toggleTag(tag), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: + isSelected + ? const Color(0xFFEEF0FF) + : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.grey[400]!, + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) ...[ + const Icon( + Icons.close, + size: 14, + color: Color(0xFF7C4DFF), + ), + const SizedBox(width: 6), + ], + Text( + tag, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: + isSelected + ? const Color(0xFF7C4DFF) + : Colors.black87, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Save Button + SizedBox( + width: double.infinity, + height: 54, + child: ElevatedButton( + onPressed: _isSaving ? null : _onSaveToMoodboard, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF212121), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(27), + ), + ), + child: + _isSaving + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Save to Moodboard', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ], + ), + ); + } +} + +class SelectionOverlayPainter extends CustomPainter { + final Rect rect; + + SelectionOverlayPainter({required this.rect}); + + @override + void paint(Canvas canvas, Size size) { + final Path backgroundPath = + Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + + final Path holePath = Path()..addRect(rect); + + final Path overlayPath = Path.combine( + PathOperation.difference, + backgroundPath, + holePath, + ); + + canvas.drawPath(overlayPath, Paint()..color = Colors.black54); + + final Paint borderPaint = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + double dashWidth = 6; + double dashSpace = 4; + Path borderPath = Path()..addRect(rect); + + for (PathMetric pathMetric in borderPath.computeMetrics()) { + double distance = 0.0; + while (distance < pathMetric.length) { + canvas.drawPath( + pathMetric.extractPath(distance, distance + dashWidth), + borderPaint, + ); + distance += (dashWidth + dashSpace); + } + } + + final Paint dotPaint = + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle( + rect.center, + 8, + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + canvas.drawCircle(rect.center, 6, dotPaint); + } + + @override + bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) { + return rect != oldDelegate.rect; + } +} diff --git a/lib/ui/pages/project_board_page.dart b/lib/ui/pages/project_board_page.dart @@ -5,6 +5,7 @@ import '../../data/models/image_model.dart'; import '../../data/repos/project_repo.dart'; import '../../data/repos/image_repo.dart'; import 'project_tag_page.dart'; +import 'image_details_page.dart'; class ProjectBoardPage extends StatefulWidget { final int projectId; @@ -317,26 +318,52 @@ class _ProjectBoardPageState extends State<ProjectBoardPage> { } Widget _buildImageCard(ImageModel image, ThemeData theme, bool isDark) { - return Container( - width: 120, - clipBehavior: Clip.antiAlias, // Ensures image doesn't overflow rounded corners - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: isDark ? Colors.black26 : Colors.grey[200], - border: Border.all(color: theme.dividerColor.withOpacity(0.1)), - ), - child: Stack( - fit: StackFit.expand, // Forces image to fill the entire container - children: [ - Image.file( - File(image.filePath), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - color: Colors.grey[300], - child: const Icon(Icons.broken_image, color: Colors.grey), - ), + return GestureDetector( + // <-- Added this wrapper + onTap: () { + // Navigate to Image Details + Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => ImageDetailsPage( + imagePath: image.filePath, + imageId: image.id, + projectId: widget.projectId, + ), ), - ], + ).then((_) { + // Refresh board when returning (in case tags changed) + _loadImagesForSelected(); + }); + }, + child: Container( + width: 120, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: isDark ? Colors.black26 : Colors.grey[200], + border: Border.all(color: theme.dividerColor.withOpacity(0.1)), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Hero( + // Optional: Add Hero for smooth zoom animation + tag: + 'image_${image.id}', // Make sure this matches the tag in ImageDetailsPage + child: Image.file( + File(image.filePath), + fit: BoxFit.cover, + errorBuilder: + (_, __, ___) => Container( + color: Colors.grey[300], + child: const Icon(Icons.broken_image, color: Colors.grey), + ), + ), + ), + ], + ), ), ); } diff --git a/lib/ui/pages/share_to_moodboard_page.dart b/lib/ui/pages/share_to_moodboard_page.dart @@ -1,10 +1,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +// Models & Repos import '../../data/models/project_model.dart'; import '../../data/repos/project_repo.dart'; + +// Services import '../../services/project_service.dart'; -import '../../services/image_service.dart'; + +// Pages +import 'image_save_page.dart'; // Helper model for UI rendering class ProjectItemViewModel { @@ -19,7 +25,9 @@ class ProjectItemViewModel { } class ShareToMoodboardPage extends StatefulWidget { + // CHANGED: Now accepts a List of files instead of a single file final List<File> imageFiles; + const ShareToMoodboardPage({super.key, required this.imageFiles}); @override @@ -29,14 +37,12 @@ class ShareToMoodboardPage extends StatefulWidget { class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final ProjectRepo _projectRepo = ProjectRepo(); final ProjectService _projectService = ProjectService(); - final ImageService _imageService = ImageService(); List<ProjectItemViewModel> _recentViewModels = []; List<ProjectItemViewModel> _allViewModels = []; List<ProjectItemViewModel> _filteredViewModels = []; bool _isLoading = true; - bool _isSaving = false; String _searchQuery = ""; final TextEditingController _searchController = TextEditingController(); @@ -46,24 +52,30 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { _loadData(); } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + Future<void> _loadData() async { setState(() => _isLoading = true); - try { - final recentItems = await _projectRepo.getRecentProjectsAndEvents(); - final allItems = await _projectRepo.getAllProjectsAndEvents(); - _recentViewModels = await _buildViewModels(recentItems.take(3).toList()); - _allViewModels = await _buildViewModels(allItems); - _filteredViewModels = _allViewModels; + // Fetch raw data + final recentItems = await _projectRepo.getRecentProjectsAndEvents(); + final allItems = await _projectRepo.getAllProjectsAndEvents(); - if (mounted) setState(() => _isLoading = false); - } catch (e) { - debugPrint("Error loading projects: $e"); - if (mounted) setState(() => _isLoading = false); - } + // Build view models with parent titles + _recentViewModels = await _buildViewModels(recentItems.take(3).toList()); + _allViewModels = await _buildViewModels(allItems); + _filteredViewModels = _allViewModels; + + setState(() => _isLoading = false); } - Future<List<ProjectItemViewModel>> _buildViewModels(List<ProjectModel> items) async { + Future<List<ProjectItemViewModel>> _buildViewModels( + List<ProjectModel> items, + ) async { final List<ProjectItemViewModel> viewModels = []; for (var item in items) { String? parentTitle; @@ -71,7 +83,9 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final parent = await _projectRepo.getProjectById(item.parentId!); parentTitle = parent?.title; } - viewModels.add(ProjectItemViewModel(item: item, parentTitle: parentTitle)); + viewModels.add( + ProjectItemViewModel(item: item, parentTitle: parentTitle), + ); } return viewModels; } @@ -82,11 +96,16 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { if (query.isEmpty) { _filteredViewModels = _allViewModels; } else { - _filteredViewModels = _allViewModels.where((vm) { - final titleMatches = vm.title.toLowerCase().contains(query.toLowerCase()); - final parentMatches = vm.parentTitle?.toLowerCase().contains(query.toLowerCase()) ?? false; - return titleMatches || parentMatches; - }).toList(); + _filteredViewModels = + _allViewModels.where((vm) { + final titleMatches = vm.title.toLowerCase().contains( + query.toLowerCase(), + ); + final parentMatches = + vm.parentTitle?.toLowerCase().contains(query.toLowerCase()) ?? + false; + return titleMatches || parentMatches; + }).toList(); } }); } @@ -95,61 +114,56 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { final controller = TextEditingController(); final String? title = await showDialog<String>( context: context, - builder: (context) => AlertDialog( - title: const Text("New Project"), - content: TextField( - controller: controller, - decoration: const InputDecoration(hintText: "Project Title"), - autofocus: true, - textCapitalization: TextCapitalization.sentences, - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text("Create"), + builder: + (context) => AlertDialog( + title: const Text("New Project"), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: "Project Title"), + autofocus: true, + textCapitalization: TextCapitalization.sentences, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text("Create"), + ), + ], ), - ], - ), ); if (title != null && title.isNotEmpty) { + // Create the project first final newId = await _projectService.createProject(title); - await _saveAndExit(newId); + _navigateToSavePage(newId, title); } } - Future<void> _saveAndExit(int projectId) async { - setState(() => _isSaving = true); - - try { - // 1. Save Images to Moodboard - await _imageService.saveImages(widget.imageFiles, projectId); + void _navigateToSavePage(int projectId, String projectName) { + // 1. Update "Recently Used" status + _projectService.openProject(projectId); - // 2. Update "Recently Used" status - await _projectService.openProject(projectId); + // 2. Reset Share Intent + ReceiveSharingIntent.instance.reset(); - // 3. Reset Share Intent - ReceiveSharingIntent.instance.reset(); - - if (mounted) { - final count = widget.imageFiles.length; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Saved $count image${count > 1 ? 's' : ''} to Moodboard!"), - backgroundColor: Colors.green, - ), - ); - Navigator.popUntil(context, (route) => route.isFirst); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Error saving: $e"), backgroundColor: Colors.red), - ); - setState(() => _isSaving = false); - } - } + // 3. Navigate to ImageSavePage with the LIST of paths + Navigator.push( + context, + MaterialPageRoute( + builder: + (_) => ImageSavePage( + // Convert List<File> to List<String> paths + imagePaths: widget.imageFiles.map((f) => f.path).toList(), + projectId: projectId, + projectName: projectName, + isFromShare: true, // This enables SystemNavigator.pop() on finish + ), + ), + ); } @override @@ -163,7 +177,10 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { icon: const Icon(Icons.arrow_back, color: Colors.black), onPressed: () => Navigator.pop(context), ), - title: const Text("Select Moodboard", style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)), + title: const Text( + "MoodBoards", + style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), actions: [ IconButton( icon: const Icon(Icons.add, color: Colors.black, size: 28), @@ -173,7 +190,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { ], ), body: - _isLoading || _isSaving + _isLoading ? const Center(child: CircularProgressIndicator()) : Column( children: [ @@ -295,7 +312,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { // --- Widget for Recent Items (Horizontal List) --- Widget _buildRecentItem(ProjectItemViewModel vm) { return InkWell( - onTap: () => _saveAndExit(vm.id), + onTap: () => _navigateToSavePage(vm.id, vm.title), borderRadius: BorderRadius.circular(16), child: Container( width: 220, @@ -357,7 +374,7 @@ class _ShareToMoodboardPageState extends State<ShareToMoodboardPage> { // --- Widget for All Items (Vertical List) --- Widget _buildAllItem(ProjectItemViewModel vm) { return ListTile( - onTap: () => _saveAndExit(vm.id), + onTap: () => _navigateToSavePage(vm.id, vm.title), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), leading: Container( width: 50, diff --git a/pubspec.yaml b/pubspec.yaml @@ -43,7 +43,7 @@ flutter: - assets/emotion_centroids.json - assets/lighting_centroids.json - assets/era_centroids.json - + fonts: - family: GeneralSans fonts: