creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
lib/ui/pages/image_save_page.dart (33896B)
1import 'dart:io';
2import 'package:flutter/material.dart';
3import 'package:flutter/services.dart';
4import 'package:creekui/data/models/canvas_models.dart';
5import 'package:creekui/services/image_service.dart';
6import 'package:creekui/services/note_service.dart';
7import 'package:creekui/utils/image_utils.dart';
8import 'package:creekui/ui/styles/variables.dart';
9import 'package:creekui/ui/widgets/primary_button.dart';
10import 'package:creekui/ui/widgets/selection_overlay_painter.dart';
11import 'package:creekui/ui/widgets/note_input_sheet.dart';
12import 'package:creekui/ui/widgets/app_bar.dart';
13import 'project_board_page.dart';
14
15// Temporary note model
16class TempNote {
17 final double normX, normY, normWidth, normHeight;
18 final String content, category;
19 TempNote({
20 required this.normX,
21 required this.normY,
22 required this.normWidth,
23 required this.normHeight,
24 required this.content,
25 required this.category,
26 });
27}
28
29class ImageSavePage extends StatefulWidget {
30 final List<String> imagePaths;
31 final int projectId;
32 final String projectName;
33 final bool isFromShare;
34 final String? parentProjectName;
35
36 const ImageSavePage({
37 super.key,
38 required this.imagePaths,
39 required this.projectId,
40 required this.projectName,
41 this.isFromShare = true,
42 this.parentProjectName,
43 });
44
45 @override
46 State<ImageSavePage> createState() => _ImageSavePageState();
47}
48
49class _ImageSavePageState extends State<ImageSavePage> {
50 final ImageService _imageService = ImageService();
51 final NoteService _noteService = NoteService();
52
53 late PageController _pageController;
54 int _currentImageIndex = 0;
55
56 final Map<int, Set<String>> _tagsPerImage = {};
57 final Map<int, List<TempNote>> _notesPerImage = {};
58
59 // A unique key for each image to calculate drawing coordinates correctly
60 late List<GlobalKey> _imageKeys;
61
62 bool _isSaving = false;
63
64 // Drawing/Resizing state
65 bool _isDrawMode =
66 false; // True when initial drag is happening (to create box)
67 bool _isResizing =
68 false; // True when a selection box is visible and resizable
69 Offset? _startPos;
70 Offset? _currentPos;
71 Rect? _finalSelectionRect;
72
73 // Resizing state
74 DragHandle _activeHandle = DragHandle.none;
75 Offset? _startDragLocalOffset; // Used for moving the entire rect
76 final Map<int, Size> _imageRenderSizes = {};
77 final double _handleSize = 25.0;
78
79 final List<String> _availableTags = [
80 'Compositions',
81 'Subject',
82 'Fonts',
83 'Background',
84 'Texture',
85 'Colours',
86 'Material Look',
87 'Lighting',
88 'Style',
89 'Era',
90 'Emotion',
91 ];
92
93 @override
94 void initState() {
95 super.initState();
96 _pageController = PageController();
97
98 // Generate a unique key for every image path
99 _imageKeys = List.generate(widget.imagePaths.length, (_) => GlobalKey());
100 for (int i = 0; i < widget.imagePaths.length; i++) {
101 _tagsPerImage[i] = {};
102 _notesPerImage[i] = [];
103 }
104 }
105
106 @override
107 void dispose() {
108 _pageController.dispose();
109 super.dispose();
110 }
111
112 // Actions
113 void _activateSelectionMode() {
114 setState(() {
115 _isDrawMode = true; // Start initial drawing mode
116 _isResizing = false;
117 _finalSelectionRect = null;
118 _startPos = null;
119 _currentPos = null;
120 });
121 }
122
123 void _resetSelectionMode() {
124 setState(() {
125 _isDrawMode = false;
126 _isResizing = false;
127 _finalSelectionRect = null;
128 _startPos = null;
129 _currentPos = null;
130 _activeHandle = DragHandle.none;
131 });
132 }
133
134 void _confirmSelectionAndShowModal() {
135 if (_finalSelectionRect != null) {
136 // Exit resizing mode before showing the modal to prevent visual conflict
137 setState(() => _isResizing = false);
138 _showNoteModal();
139 }
140 }
141
142 void _toggleTag(String tag) {
143 setState(() {
144 final tags = _tagsPerImage[_currentImageIndex]!;
145 if (tags.contains(tag))
146 tags.remove(tag);
147 else
148 tags.add(tag);
149 });
150 }
151
152 // Gestures
153 Offset? _getLocalPosition(Offset globalPosition) {
154 final currentKey = _imageKeys[_currentImageIndex];
155 final RenderBox? box =
156 currentKey.currentContext?.findRenderObject() as RenderBox?;
157 if (box == null) return null;
158 _imageRenderSizes[_currentImageIndex] = box.size;
159 final local = box.globalToLocal(globalPosition);
160
161 // Clamp coordinates to ensure we don't draw/drag outside the image
162 final dx = local.dx.clamp(0.0, box.size.width);
163 final dy = local.dy.clamp(0.0, box.size.height);
164 return Offset(dx, dy);
165 }
166
167 DragHandle _getDragHandle(Offset pos) {
168 if (_finalSelectionRect == null) return DragHandle.none;
169 final rect = _finalSelectionRect!;
170
171 // Check corners first
172 if (Rect.fromCircle(
173 center: rect.topLeft,
174 radius: _handleSize,
175 ).contains(pos))
176 return DragHandle.topLeft;
177 if (Rect.fromCircle(
178 center: rect.topRight,
179 radius: _handleSize,
180 ).contains(pos))
181 return DragHandle.topRight;
182 if (Rect.fromCircle(
183 center: rect.bottomLeft,
184 radius: _handleSize,
185 ).contains(pos))
186 return DragHandle.bottomLeft;
187 if (Rect.fromCircle(
188 center: rect.bottomRight,
189 radius: _handleSize,
190 ).contains(pos))
191 return DragHandle.bottomRight;
192 if (rect.contains(pos)) return DragHandle.center;
193 return DragHandle.none;
194 }
195
196 void _onPanStart(DragStartDetails details) {
197 if (_isDrawMode) {
198 final pos = _getLocalPosition(details.globalPosition);
199 if (pos == null) return;
200 setState(() {
201 _startPos = pos;
202 _currentPos = pos;
203 });
204 } else if (_isResizing && _finalSelectionRect != null) {
205 final pos = _getLocalPosition(details.globalPosition);
206 if (pos == null) return;
207 final handle = _getDragHandle(pos);
208 if (handle != DragHandle.none) {
209 setState(() {
210 _activeHandle = handle;
211 if (handle == DragHandle.center) {
212 _startDragLocalOffset = pos - _finalSelectionRect!.topLeft;
213 }
214 });
215 }
216 }
217 }
218
219 void _onPanUpdate(DragUpdateDetails details) {
220 if (_isDrawMode) {
221 final pos = _getLocalPosition(details.globalPosition);
222 if (pos == null) return;
223 setState(() => _currentPos = pos);
224 } else if (_isResizing && _finalSelectionRect != null) {
225 final pos = _getLocalPosition(details.globalPosition);
226 if (pos == null || _activeHandle == DragHandle.none) return;
227
228 setState(() {
229 Rect newRect = _finalSelectionRect!;
230 final newPoint = pos;
231
232 switch (_activeHandle) {
233 case DragHandle.topLeft:
234 newRect = Rect.fromLTRB(
235 newPoint.dx,
236 newPoint.dy,
237 newRect.right,
238 newRect.bottom,
239 );
240 break;
241 case DragHandle.topRight:
242 newRect = Rect.fromLTRB(
243 newRect.left,
244 newPoint.dy,
245 newPoint.dx,
246 newRect.bottom,
247 );
248 break;
249 case DragHandle.bottomLeft:
250 newRect = Rect.fromLTRB(
251 newPoint.dx,
252 newRect.top,
253 newRect.right,
254 newPoint.dy,
255 );
256 break;
257 case DragHandle.bottomRight:
258 newRect = Rect.fromLTRB(
259 newRect.left,
260 newRect.top,
261 newPoint.dx,
262 newPoint.dy,
263 );
264 break;
265 case DragHandle.center:
266 if (_startDragLocalOffset != null) {
267 final newTopLeft = newPoint - _startDragLocalOffset!;
268 newRect = Rect.fromLTWH(
269 newTopLeft.dx,
270 newTopLeft.dy,
271 newRect.width,
272 newRect.height,
273 );
274 }
275 break;
276 case DragHandle.none:
277 return;
278 }
279
280 final imageSize = _imageRenderSizes[_currentImageIndex];
281 if (imageSize != null) {
282 final cl = newRect.left.clamp(0.0, imageSize.width);
283 final ct = newRect.top.clamp(0.0, imageSize.height);
284 final cr = newRect.right.clamp(0.0, imageSize.width);
285 final cb = newRect.bottom.clamp(0.0, imageSize.height);
286 newRect = Rect.fromLTRB(cl, ct, cr, cb).normalize();
287 } else {
288 newRect = newRect.normalize();
289 }
290
291 if (newRect.width > 10 && newRect.height > 10)
292 _finalSelectionRect = newRect;
293 });
294 }
295 }
296
297 void _onPanEnd(DragEndDetails details) {
298 if (_isDrawMode && _startPos != null && _currentPos != null) {
299 final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize();
300 if (rect.width < 10 || rect.height < 10) {
301 _resetSelectionMode();
302 return;
303 }
304 setState(() {
305 _isDrawMode = false;
306 _isResizing = true;
307 _finalSelectionRect = rect;
308 _startPos = null;
309 _currentPos = null;
310 });
311 } else if (_isResizing) {
312 setState(() {
313 _activeHandle = DragHandle.none;
314 _startDragLocalOffset = null;
315 });
316 }
317 }
318
319 // Modal
320 void _showNoteModal() {
321 String initialCategory = _availableTags.firstOrNull ?? 'General';
322 showModalBottomSheet(
323 context: context,
324 isScrollControlled: true,
325 backgroundColor: Colors.transparent,
326 builder:
327 (context) => NoteModalOverlay(
328 screenSize: MediaQuery.of(context).size,
329 modalContent: NoteInputSheet(
330 categories: _availableTags,
331 initialCategory: initialCategory,
332 onSubmit: (content, category) {
333 final imageSize = _imageRenderSizes[_currentImageIndex];
334 if (_finalSelectionRect != null && imageSize != null) {
335 final nX = _finalSelectionRect!.center.dx / imageSize.width;
336 final nY = _finalSelectionRect!.center.dy / imageSize.height;
337 final nW = _finalSelectionRect!.width / imageSize.width;
338 final nH = _finalSelectionRect!.height / imageSize.height;
339
340 final newNote = TempNote(
341 normX: nX,
342 normY: nY,
343 normWidth: nW,
344 normHeight: nH,
345 content: content,
346 category: category,
347 );
348 setState(() {
349 _notesPerImage[_currentImageIndex]?.add(newNote);
350 _finalSelectionRect = null;
351 });
352 Navigator.pop(context);
353 }
354 },
355 ),
356 ),
357 );
358 }
359
360 // Final Save
361 Future<void> _onSaveToMoodboard() async {
362 setState(() => _isSaving = true);
363 try {
364 for (int i = 0; i < widget.imagePaths.length; i++) {
365 String path = widget.imagePaths[i];
366 final file = File(path);
367 if (!file.existsSync()) continue;
368
369 final tags = _tagsPerImage[i] ?? {};
370 final imageId = await _imageService.saveOrUpdateImage(
371 file,
372 widget.projectId,
373 tags: tags.toList(),
374 );
375
376 final notes = _notesPerImage[i] ?? [];
377 for (var note in notes) {
378 await _noteService.addNote(
379 imageId,
380 note.content,
381 note.category,
382 normX: note.normX,
383 normY: note.normY,
384 normWidth: note.normWidth,
385 normHeight: note.normHeight,
386 );
387 }
388 }
389
390 if (mounted) {
391 ScaffoldMessenger.of(context).showSnackBar(
392 const SnackBar(
393 content: Text('All images saved successfully!'),
394 backgroundColor: Colors.green,
395 ),
396 );
397 if (widget.isFromShare)
398 SystemNavigator.pop();
399 else
400 Navigator.pushAndRemoveUntil(
401 context,
402 MaterialPageRoute(
403 builder:
404 (_) => ProjectBoardPage(
405 projectId: widget.projectId,
406 initialShowAlternateView: true,
407 ),
408 ),
409 (route) => false,
410 );
411 }
412 } catch (e) {
413 if (mounted) {
414 ScaffoldMessenger.of(
415 context,
416 ).showSnackBar(SnackBar(content: Text('Error: $e')));
417 setState(() => _isSaving = false);
418 }
419 }
420 }
421
422 @override
423 Widget build(BuildContext context) {
424 final currentTags = _tagsPerImage[_currentImageIndex] ?? {};
425 final String parentName = widget.parentProjectName?.trim() ?? '';
426 final String titleText =
427 parentName.isNotEmpty
428 ? '$parentName / ${widget.projectName}'
429 : widget.projectName;
430
431 // Determine if user can pan/zoom the image carousel
432 final isPageLocked = _isDrawMode || _isResizing;
433
434 return Scaffold(
435 resizeToAvoidBottomInset: false,
436 backgroundColor: Variables.surfaceBackground,
437 appBar: CommonAppBar(
438 title: titleText,
439 leading: IconButton(
440 icon: const Icon(
441 Icons.arrow_back_ios_new,
442 size: 20,
443 color: Variables.textPrimary,
444 ),
445 onPressed: () => Navigator.pop(context),
446 ),
447 actions: [
448 // Confirm Selection Button (Visible only in resizing mode)
449 if (_isResizing && _finalSelectionRect != null)
450 IconButton(
451 icon: const Icon(Icons.check, color: Color(0xFF7C86FF), size: 24),
452 onPressed: _confirmSelectionAndShowModal,
453 ),
454 // Cancel Selection Button (Visible only in drawing/resizing mode)
455 if (_isDrawMode || _isResizing)
456 IconButton(
457 icon: const Icon(Icons.close, color: Colors.black87, size: 24),
458 onPressed: _resetSelectionMode,
459 ),
460 ],
461 ),
462 body: Column(
463 children: [
464 // Horizontal Image Carousel
465 Expanded(
466 child: Container(
467 width: double.infinity,
468 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
469 decoration: BoxDecoration(
470 color: Colors.black,
471 borderRadius: BorderRadius.circular(16),
472 ),
473 child: ClipRRect(
474 borderRadius: BorderRadius.circular(16),
475 child: Stack(
476 fit: StackFit.expand,
477 children: [
478 PageView.builder(
479 controller: _pageController,
480 // Disable page view scrolling if a selection process is active
481 physics:
482 isPageLocked
483 ? const NeverScrollableScrollPhysics()
484 : const PageScrollPhysics(),
485 itemCount: widget.imagePaths.length,
486 onPageChanged:
487 (index) => setState(() {
488 _currentImageIndex = index;
489 _resetSelectionMode();
490 }),
491 itemBuilder: (context, index) {
492 return LayoutBuilder(
493 builder: (context, constraints) {
494 // Determine the gesture handler based on the mode
495 final onPanStartHandler =
496 _isDrawMode
497 ? _onPanStart
498 : (_isResizing &&
499 index == _currentImageIndex
500 ? _onPanStart
501 : null);
502 final onPanUpdateHandler =
503 _isDrawMode
504 ? _onPanUpdate
505 : (_isResizing &&
506 index == _currentImageIndex
507 ? _onPanUpdate
508 : null);
509 final onPanEndHandler =
510 _isDrawMode
511 ? _onPanEnd
512 : (_isResizing &&
513 index == _currentImageIndex
514 ? _onPanEnd
515 : null);
516
517 return Stack(
518 fit: StackFit.expand,
519 children: [
520 InteractiveViewer(
521 // Disable pan/scale if selection or resizing is active
522 panEnabled: !isPageLocked,
523 scaleEnabled: !isPageLocked,
524 child: Center(
525 child: GestureDetector(
526 onPanStart: onPanStartHandler,
527 onPanUpdate: onPanUpdateHandler,
528 onPanEnd: onPanEndHandler,
529 child: Stack(
530 children: [
531 Image.file(
532 File(widget.imagePaths[index]),
533 key: _imageKeys[index],
534 fit: BoxFit.contain,
535 width: double.infinity,
536 ),
537 // Drawing Overlay (if in drawing mode)
538 if (_isDrawMode &&
539 index == _currentImageIndex &&
540 _startPos != null &&
541 _currentPos != null)
542 Positioned.fill(
543 child: CustomPaint(
544 painter:
545 SelectionOverlayPainter(
546 rect:
547 Rect.fromPoints(
548 _startPos!,
549 _currentPos!,
550 ).normalize(),
551 isResizing: false,
552 ),
553 ),
554 ),
555 // Final Selection Rect (if in resizing mode)
556 if (_isResizing &&
557 index == _currentImageIndex &&
558 _finalSelectionRect != null)
559 Positioned.fill(
560 child: CustomPaint(
561 painter:
562 SelectionOverlayPainter(
563 rect:
564 _finalSelectionRect!,
565 isResizing: true,
566 activeHandle:
567 _activeHandle,
568 ),
569 ),
570 ),
571 ],
572 ),
573 ),
574 ),
575 ),
576 // Existing Note Indicators (Dots) - visible only if no selection is active
577 if (!isPageLocked)
578 ...(_notesPerImage[index] ?? []).map((note) {
579 return Positioned(
580 left:
581 (note.normX * constraints.maxWidth) -
582 10,
583 top:
584 (note.normY * constraints.maxHeight) -
585 10,
586 child: Container(
587 width: 20,
588 height: 20,
589 decoration: BoxDecoration(
590 color: Colors.white,
591 shape: BoxShape.circle,
592 boxShadow: [
593 BoxShadow(
594 color: Colors.black.withOpacity(
595 0.3,
596 ),
597 blurRadius: 4,
598 offset: const Offset(0, 1),
599 ),
600 ],
601 border: Border.all(
602 color: const Color(0xFF7C4DFF),
603 width: 2,
604 ),
605 ),
606 ),
607 );
608 }),
609 // Page Dots
610 if (widget.imagePaths.length > 1 &&
611 !isPageLocked)
612 Positioned(
613 bottom: 12,
614 left: 0,
615 right: 0,
616 child: Row(
617 mainAxisAlignment:
618 MainAxisAlignment.center,
619 children: List.generate(
620 widget.imagePaths.length,
621 (i) => Container(
622 margin: const EdgeInsets.symmetric(
623 horizontal: 4,
624 ),
625 width: 8,
626 height: 8,
627 decoration: BoxDecoration(
628 shape: BoxShape.circle,
629 color:
630 _currentImageIndex == i
631 ? Colors.blue
632 : Colors.white.withOpacity(
633 0.5,
634 ),
635 ),
636 ),
637 ),
638 ),
639 ),
640 // Notes Button (Visible only when not drawing/resizing)
641 if (!isPageLocked)
642 Positioned(
643 bottom: 24,
644 right: 12,
645 child: ElevatedButton.icon(
646 onPressed: _activateSelectionMode,
647 style: ElevatedButton.styleFrom(
648 backgroundColor: Colors.white,
649 foregroundColor: Colors.black,
650 padding: const EdgeInsets.symmetric(
651 horizontal: 16,
652 vertical: 12,
653 ),
654 shape: RoundedRectangleBorder(
655 borderRadius: BorderRadius.circular(
656 12,
657 ),
658 ),
659 ),
660 icon: const Icon(
661 Icons.assignment_outlined,
662 size: 18,
663 ),
664 label: const Text(
665 "Notes",
666 style: TextStyle(
667 fontFamily: 'GeneralSans',
668 fontSize: 14,
669 fontWeight: FontWeight.w500,
670 ),
671 ),
672 ),
673 ),
674 // Instruction overlay (for initial drawing)
675 if (_isDrawMode && _startPos == null)
676 Positioned(
677 top: 20,
678 left: 0,
679 right: 0,
680 child: Center(
681 child: Container(
682 padding: const EdgeInsets.symmetric(
683 horizontal: 16,
684 vertical: 8,
685 ),
686 decoration: BoxDecoration(
687 color: Colors.black87,
688 borderRadius: BorderRadius.circular(
689 20,
690 ),
691 ),
692 child: const Text(
693 "Drag on image to select area",
694 style: TextStyle(
695 color: Colors.white,
696 fontSize: 12,
697 ),
698 ),
699 ),
700 ),
701 ),
702 // Instruction overlay (for resizing)
703 if (_isResizing &&
704 _finalSelectionRect != null &&
705 _activeHandle == DragHandle.none)
706 Positioned(
707 top: 20,
708 left: 0,
709 right: 0,
710 child: Center(
711 child: Container(
712 padding: const EdgeInsets.symmetric(
713 horizontal: 16,
714 vertical: 8,
715 ),
716 decoration: BoxDecoration(
717 color: Colors.black87,
718 borderRadius: BorderRadius.circular(
719 20,
720 ),
721 ),
722 child: const Text(
723 "Adjust area or tap checkmark to confirm",
724 style: TextStyle(
725 color: Colors.white,
726 fontSize: 12,
727 ),
728 ),
729 ),
730 ),
731 ),
732 ],
733 );
734 },
735 );
736 },
737 ),
738 ],
739 ),
740 ),
741 ),
742 ),
743 // Bottom Form (Tags & Save)
744 Padding(
745 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
746 child: Column(
747 crossAxisAlignment: CrossAxisAlignment.start,
748 children: [
749 // Tags box
750 Container(
751 width: double.infinity,
752 decoration: BoxDecoration(
753 color: const Color(0xFFFEFEFE),
754 border: Border.all(color: Variables.borderSubtle),
755 borderRadius: BorderRadius.circular(16),
756 ),
757 child: Column(
758 crossAxisAlignment: CrossAxisAlignment.start,
759 children: [
760 // Header section
761 Padding(
762 padding: const EdgeInsets.all(16),
763 child: Row(
764 mainAxisAlignment: MainAxisAlignment.spaceBetween,
765 children: [
766 const Text(
767 'What did you like about this image?',
768 style: TextStyle(
769 fontFamily: 'GeneralSans',
770 fontSize: 14,
771 fontWeight: FontWeight.w500,
772 ),
773 ),
774 if (widget.imagePaths.length > 1)
775 Text(
776 "Image ${_currentImageIndex + 1}/${widget.imagePaths.length}",
777 style: Variables.captionStyle,
778 ),
779 ],
780 ),
781 ),
782 const Divider(
783 height: 0,
784 thickness: 1,
785 color: Color(0xFFE4E4E7),
786 ),
787 // Content section with tags
788 Padding(
789 padding: const EdgeInsets.symmetric(
790 horizontal: 16,
791 vertical: 12,
792 ),
793 child: Wrap(
794 spacing: 8,
795 runSpacing: 8,
796 children:
797 _availableTags.map((tag) {
798 final isSelected = currentTags.contains(tag);
799 return GestureDetector(
800 onTap: () => _toggleTag(tag),
801 child: Container(
802 padding: const EdgeInsets.symmetric(
803 horizontal: 14,
804 vertical: 8,
805 ),
806 decoration: BoxDecoration(
807 color:
808 isSelected
809 ? const Color(0xFFE0E7FF)
810 : Colors.white,
811 borderRadius: BorderRadius.circular(20),
812 border: Border.all(
813 color:
814 isSelected
815 ? const Color(0xFF7C86FF)
816 : Colors.grey[300]!,
817 ),
818 ),
819 child: Text(
820 tag,
821 style: TextStyle(
822 fontFamily: 'GeneralSans',
823 fontSize: 14,
824 color:
825 isSelected
826 ? const Color(0xFF27272A)
827 : Colors.black87,
828 ),
829 ),
830 ),
831 );
832 }).toList(),
833 ),
834 ),
835 ],
836 ),
837 ),
838 const SizedBox(height: 24),
839 PrimaryButton(
840 text:
841 'Save ${widget.imagePaths.length > 1 ? "All" : ""} to Moodboard',
842 isLoading: _isSaving,
843 onPressed: _onSaveToMoodboard,
844 ),
845 ],
846 ),
847 ),
848 ],
849 ),
850 );
851 }
852}