creek

The AI Image Editor of 2030
commit 0bfb142985550fc2d9a5eabe6ecf10c803aa0f8b
parent fe164aad35ddc7e9aac86e2b8ee29a18fbfd8d84
Author: Sanjeebani Parida <83271316+sanjeebani14@users.noreply.github.com>
Date:   Sun, 30 Nov 2025 00:00:42 +0530

Merge pull request #20 from nilotpal-n7/sanjeebani

Sanjeebani
Diffstat:
Mlib/ui/pages/image_details_page.dart | 883+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mlib/ui/pages/image_save_page.dart | 878+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
2 files changed, 1319 insertions(+), 442 deletions(-)

diff --git a/lib/ui/pages/image_details_page.dart b/lib/ui/pages/image_details_page.dart @@ -1,11 +1,53 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../services/image_service.dart'; import '../../services/note_service.dart'; import '../../data/models/note_model.dart'; import '../../data/models/image_model.dart'; +// --- STATE MACHINE FOR SELECTION MODE --- +enum DragHandle { + none, + topLeft, + topRight, + bottomLeft, + bottomRight, + center, // For dragging the entire box +} + +// --- HELPER CLASS FOR TEMPORARY NOTES (Needed for consistency) --- +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, + }); +} + +// --- EXTENSION TO NORMALIZE RECT --- +extension on Rect { + Rect normalize() { + return Rect.fromLTRB( + left < right ? left : right, + top < bottom ? top : bottom, + left > right ? left : right, + top > bottom ? top : bottom, + ); + } +} + class ImageDetailsPage extends StatefulWidget { final String imagePath; final String imageId; @@ -34,8 +76,9 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { bool _isLoading = true; int? _activeNoteId; - // -- DRAWING STATE -- + // -- DRAWING/RESIZING STATE -- bool _isDrawMode = false; + bool _isResizing = false; final GlobalKey _imageKey = GlobalKey(); Offset? _startPos; @@ -43,6 +86,12 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { Rect? _finalSelectionRect; Size? _imageRenderSize; + // Resizing state + DragHandle _activeHandle = DragHandle.none; + Offset? _startDragLocalOffset; + + final double _handleSize = 25.0; // Resizing constant + // Master List of Tags final List<String> _allAvailableTags = [ 'Compositions', @@ -86,15 +135,36 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { // --- ACTIONS --- - void _activateDrawMode() { + void _resetSelectionMode() { setState(() { - _isDrawMode = true; + _isDrawMode = false; + _isResizing = false; _finalSelectionRect = null; _startPos = null; _currentPos = null; + _activeHandle = DragHandle.none; + _startDragLocalOffset = null; }); } + void _activateDrawMode() { + _resetSelectionMode(); // Reset any previous selection + setState(() { + _isDrawMode = true; // Start initial drawing mode + }); + } + + void _confirmSelectionAndShowModal() { + if (_finalSelectionRect != null) { + // Exit resizing mode before showing the modal + setState(() { + _isResizing = false; + _activeHandle = DragHandle.none; + }); + _showAddNoteInputDialog(); + } + } + void _openNotesSheet({int? highlightId}) { setState(() => _activeNoteId = highlightId); @@ -116,45 +186,229 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { }); } - // --- DRAWING GESTURES --- + // --- DRAWING/RESIZING GESTURES --- + + Offset? _getLocalPosition(Offset globalPosition) { + final RenderBox? box = + _imageKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return null; + + // Store the render size + _imageRenderSize = box.size; + // Convert global to local + final local = box.globalToLocal(globalPosition); + + // Clamp coordinates to ensure we don't draw/drag outside the image + final dx = local.dx.clamp(0.0, box.size.width); + final dy = local.dy.clamp(0.0, box.size.height); + + return Offset(dx, dy); + } + + // --- DRAWING HANDLERS --- void _onPanStart(DragStartDetails details) { - if (!_isDrawMode) return; - setState(() { - _startPos = details.localPosition; - _currentPos = details.localPosition; - }); + if (_isDrawMode) { + // START DRAWING + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + setState(() { + _startPos = pos; + _currentPos = pos; + }); + } else if (_isResizing && _finalSelectionRect != null) { + // START RESIZING/MOVING + _onResizeStart(details); + } } void _onPanUpdate(DragUpdateDetails details) { - if (!_isDrawMode) return; - setState(() { - _currentPos = details.localPosition; - }); + if (_isDrawMode) { + // DRAWING + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + setState(() { + _currentPos = pos; + }); + } else if (_isResizing && _finalSelectionRect != null) { + // RESIZING/MOVING + _onResizeUpdate(details); + } } void _onPanEnd(DragEndDetails details) { - if (!_isDrawMode || _startPos == null || _currentPos == null) return; + if (_isDrawMode && _startPos != null && _currentPos != null) { + // END DRAWING, TRANSITION TO RESIZING MODE + final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize(); - final rect = Rect.fromPoints(_startPos!, _currentPos!); - final RenderBox? box = - _imageKey.currentContext?.findRenderObject() as RenderBox?; - if (box != null) { - _imageRenderSize = box.size; + if (rect.width < 10 || rect.height < 10) { + _resetSelectionMode(); + return; + } + + setState(() { + _isDrawMode = false; + _isResizing = true; // Enter resizing/confirming mode + _finalSelectionRect = rect; + _startPos = null; + _currentPos = null; + }); + } else if (_isResizing) { + // END RESIZING/MOVING + _onResizeEnd(details); + } + } + + // --- RESIZING HANDLERS --- + DragHandle _getDragHandle(Offset pos) { + if (_finalSelectionRect == null) return DragHandle.none; + + final rect = _finalSelectionRect!; + + // Check corners + if (Rect.fromCircle( + center: rect.topLeft, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.topLeft; + } else if (Rect.fromCircle( + center: rect.topRight, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.topRight; + } else if (Rect.fromCircle( + center: rect.bottomLeft, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.bottomLeft; + } else if (Rect.fromCircle( + center: rect.bottomRight, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.bottomRight; + } + // Check if dragging the whole box (center) + else if (rect.contains(pos)) { + return DragHandle.center; + } + + return DragHandle.none; + } + + void _onResizeStart(DragStartDetails details) { + if (!_isResizing || _finalSelectionRect == null) return; + + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + + final handle = _getDragHandle(pos); + if (handle != DragHandle.none) { + setState(() { + _activeHandle = handle; + // Calculate offset for moving the entire rect + if (handle == DragHandle.center) { + _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; + } + }); } + } - _finalSelectionRect = rect; + void _onResizeUpdate(DragUpdateDetails details) { + if (!_isResizing || + _finalSelectionRect == null || + _activeHandle == DragHandle.none) + return; + + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; setState(() { - _isDrawMode = false; - _startPos = null; - _currentPos = null; + Rect newRect = _finalSelectionRect!; + final newPoint = pos; + + switch (_activeHandle) { + case DragHandle.topLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newPoint.dy, + newRect.right, + newRect.bottom, + ); + break; + case DragHandle.topRight: + newRect = Rect.fromLTRB( + newRect.left, + newPoint.dy, + newPoint.dx, + newRect.bottom, + ); + break; + case DragHandle.bottomLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newRect.top, + newRect.right, + newPoint.dy, + ); + break; + case DragHandle.bottomRight: + newRect = Rect.fromLTRB( + newRect.left, + newRect.top, + newPoint.dx, + newPoint.dy, + ); + break; + case DragHandle.center: + if (_startDragLocalOffset != null) { + final newTopLeft = newPoint - _startDragLocalOffset!; + newRect = Rect.fromLTWH( + newTopLeft.dx, + newTopLeft.dy, + newRect.width, + newRect.height, + ); + } + break; + case DragHandle.none: + return; + } + + // Clamp the final rectangle to the image boundaries (0,0 to width, height) + final imageSize = _imageRenderSize; + if (imageSize != null) { + final clampedLeft = newRect.left.clamp(0.0, imageSize.width); + final clampedTop = newRect.top.clamp(0.0, imageSize.height); + final clampedRight = newRect.right.clamp(0.0, imageSize.width); + final clampedBottom = newRect.bottom.clamp(0.0, imageSize.height); + + newRect = + Rect.fromLTRB( + clampedLeft, + clampedTop, + clampedRight, + clampedBottom, + ).normalize(); + } else { + newRect = newRect.normalize(); + } + + // Ensure min size + if (newRect.width > 10 && newRect.height > 10) { + _finalSelectionRect = newRect; + } }); + } - _showAddNoteInputDialog(); + void _onResizeEnd(DragEndDetails details) { + if (!_isResizing) return; + setState(() { + _activeHandle = DragHandle.none; + _startDragLocalOffset = null; + }); } - // --- ADD NOTE INPUT DIALOG --- + // --- ADD NOTE INPUT DIALOG (FIXED KEYBOARD/MARGIN) --- void _showAddNoteInputDialog() { final TextEditingController newNoteController = TextEditingController(); String newCategory = 'Compositions'; @@ -163,138 +417,136 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { 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, + builder: (context) { + final mediaQuery = MediaQuery.of(context); + + final modalContent = StatefulBuilder( + builder: (BuildContext context, StateSetter setModalState) { + return Column( + // mainAxisSize.min ensures the column takes only the height it needs + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 20, left: 20, right: 20), + child: Text( + "Add Note", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ), + const SizedBox(height: 16), + // Category Dropdown + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: DropdownButtonFormField<String>( + value: newCategory, + decoration: InputDecoration( + labelText: "Category", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), ), - 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: + _allAvailableTags + .map( + (c) => DropdownMenuItem(value: c, child: Text(c)), + ) + .toList(), + onChanged: (v) => setModalState(() => newCategory = v!), + ), + ), + const SizedBox(height: 12), + // Note Text + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextField( + controller: newNoteController, + autofocus: true, + maxLines: 3, + decoration: InputDecoration( + hintText: "Enter note details...", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - items: - [ - 'Compositions', - 'Subject', - 'Fonts', - 'Background', - 'Texture', - 'Colours', - 'Material Look', - 'Lighting', - 'Style', - 'Era', - 'Emotion', - ] - .map( - (c) => - DropdownMenuItem(value: c, child: Text(c)), - ) - .toList(), - onChanged: (v) => newCategory = v!, ), - - const SizedBox(height: 12), - - TextField( - controller: newNoteController, - autofocus: true, - maxLines: 3, - decoration: InputDecoration( - hintText: "Enter note details...", - border: OutlineInputBorder( + ), + ), + const SizedBox(height: 16), + // Save Button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), - ), + onPressed: () async { + if (newNoteController.text.isNotEmpty && + _finalSelectionRect != null && + _imageRenderSize != null) { + // Normalization logic + final normalizedRect = + _finalSelectionRect!.normalize(); + final nX = + normalizedRect.center.dx / + _imageRenderSize!.width; + final nY = + normalizedRect.center.dy / + _imageRenderSize!.height; + final nW = + normalizedRect.width / _imageRenderSize!.width; + final nH = + normalizedRect.height / _imageRenderSize!.height; - const SizedBox(height: 16), + await _noteService.addNote( + widget.imageId, + newNoteController.text.trim(), + newCategory, + normX: nX, + normY: nY, + normWidth: nW, + normHeight: nH, + ); - SizedBox( - width: double.infinity, - height: 50, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - 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, - ); - - final updatedNotes = await _noteService - .getNotesForImage(widget.imageId); - setState(() { - _notes = updatedNotes; - }); + final updatedNotes = await _noteService + .getNotesForImage(widget.imageId); + // Update the main state of ImageDetailsPage + this.setState(() { + _notes = updatedNotes; + _finalSelectionRect = null; // Clear selection + }); - Navigator.pop(context); - } - }, - child: const Text( - "Save Note", - style: TextStyle(fontWeight: FontWeight.bold), - ), + Navigator.pop(context); + } + }, + child: const Text( + "Save Note", + style: TextStyle(fontWeight: FontWeight.bold), ), ), - ], + ), ), - ), - ), - ), + const SizedBox(height: 20), + ], + ); + }, + ); + + // Use the custom overlay for keyboard margin fix and no shadow/dimming + return NoteModalOverlay( + modalContent: modalContent, + screenSize: mediaQuery.size, + ); + }, ); } @@ -410,7 +662,12 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { @override Widget build(BuildContext context) { + // Determine if user can pan/zoom the image carousel + final isSelectionModeActive = _isDrawMode || _isResizing; + return Scaffold( + // FIX: Prevents the main screen from pushing up when the keyboard opens + resizeToAvoidBottomInset: false, backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, @@ -423,8 +680,32 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ), onPressed: () => Navigator.pop(context), ), + title: Text( + _imageModel?.name ?? "Image Details", + style: const TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + // CONFIRM SELECTION BUTTON (Visible only in resizing mode) + if (_isResizing && _finalSelectionRect != null) + IconButton( + icon: const Icon( + Icons.check_circle_outline, + color: Color(0xFF7C4DFF), + ), + onPressed: _confirmSelectionAndShowModal, + ), + // CANCEL SELECTION BUTTON (Visible only in drawing/resizing mode) + if (isSelectionModeActive) + IconButton( + icon: const Icon(Icons.close, color: Colors.black), + onPressed: _resetSelectionMode, + ), + ], ), - body: _isLoading ? const Center( @@ -449,19 +730,29 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { borderRadius: BorderRadius.circular(20), child: LayoutBuilder( builder: (context, constraints) { + // Unified Pan Handlers + final onPanStartHandler = + isSelectionModeActive + ? _onPanStart + : null; + final onPanUpdateHandler = + isSelectionModeActive + ? _onPanUpdate + : null; + final onPanEndHandler = + isSelectionModeActive ? _onPanEnd : null; + return Stack( fit: StackFit.passthrough, children: [ InteractiveViewer( - panEnabled: !_isDrawMode, - scaleEnabled: !_isDrawMode, + // Disable pan/scale if selection or resizing is active + panEnabled: !isSelectionModeActive, + scaleEnabled: !isSelectionModeActive, child: GestureDetector( - onPanStart: - _isDrawMode ? _onPanStart : null, - onPanUpdate: - _isDrawMode ? _onPanUpdate : null, - onPanEnd: - _isDrawMode ? _onPanEnd : null, + onPanStart: onPanStartHandler, + onPanUpdate: onPanUpdateHandler, + onPanEnd: onPanEndHandler, child: Stack( children: [ Image.file( @@ -471,17 +762,36 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { width: double.infinity, ), + // DRAWING OVERLAY (if in drawing mode) if (_isDrawMode && _startPos != null && _currentPos != null) Positioned.fill( child: CustomPaint( painter: - SelectionOverlayPainter( - rect: Rect.fromPoints( - _startPos!, - _currentPos!, - ), + ResizingSelectionOverlayPainter( + rect: + Rect.fromPoints( + _startPos!, + _currentPos!, + ).normalize(), + isResizing: false, + ), + ), + ), + + // FINAL SELECTION RECT (if in resizing mode) + if (_isResizing && + _finalSelectionRect != null) + Positioned.fill( + child: CustomPaint( + painter: + ResizingSelectionOverlayPainter( + rect: + _finalSelectionRect!, + isResizing: true, + activeHandle: + _activeHandle, ), ), ), @@ -490,6 +800,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ), ), + // Existing Note Indicators ..._notes.map((note) { final x = note.normX * constraints.maxWidth; @@ -535,46 +846,78 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { ); }).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, + // Notes Button (Show only when not in selection mode) + if (!isSelectionModeActive) + 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), + ), ), - 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, + ), + ], ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Text( - "Notes", + ), + + // INSTRUCTION OVERLAYS + 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 on image to select area", style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, + color: Colors.white, + fontSize: 12, ), ), - SizedBox(width: 8), - Icon( - Icons.assignment_outlined, - size: 18, - ), - ], + ), ), ), - ), - - if (_isDrawMode && _startPos == null) + if (_isResizing && + _finalSelectionRect != null && + _activeHandle == DragHandle.none) Positioned( top: 20, left: 0, @@ -592,7 +935,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { BorderRadius.circular(20), ), child: const Text( - "Drag on image to select area", + "Adjust area or tap Checkmark to confirm", style: TextStyle( color: Colors.white, fontSize: 12, @@ -739,7 +1082,7 @@ class _ImageDetailsPageState extends State<ImageDetailsPage> { } } -// --- NOTES LIST SHEET --- +// --- NOTES LIST SHEET (Remains unchanged as it uses DraggableScrollableSheet) --- class _NotesListSheet extends StatefulWidget { final List<NoteModel> notes; final int? highlightId; @@ -753,10 +1096,10 @@ class _NotesListSheet extends StatefulWidget { }) : super(key: key); @override - State<_NotesListSheet> createState() => _NotesListSheetState(); + State<_NotesListSheet> createState() => __NotesListSheetState(); } -class _NotesListSheetState extends State<_NotesListSheet> { +class __NotesListSheetState extends State<_NotesListSheet> { final ScrollController _scrollController = ScrollController(); @override @@ -804,7 +1147,6 @@ class _NotesListSheetState extends State<_NotesListSheet> { ), ), ), - Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -822,7 +1164,6 @@ class _NotesListSheetState extends State<_NotesListSheet> { ], ), const Divider(), - Expanded( child: ListView.builder( controller: controller, @@ -887,7 +1228,6 @@ class _NotesListSheetState extends State<_NotesListSheet> { }, ), ), - // --- ADD NOTE BUTTON (BOTTOM OF SHEET) --- const SizedBox(height: 10), SizedBox( @@ -914,31 +1254,99 @@ class _NotesListSheetState extends State<_NotesListSheet> { } } -class SelectionOverlayPainter extends CustomPainter { +// --- REUSABLE WIDGETS FOR MODAL OVERLAY (Keyboard Fix and Shadow Removal) --- + +class NoteModalOverlay extends StatelessWidget { + final Widget modalContent; + final Size screenSize; + + const NoteModalOverlay({ + Key? key, + required this.modalContent, + required this.screenSize, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Note: modalMinHeight is kept only for potential use with MaxHeight, but is not enforced as a minimum. + final mq = MediaQuery.of(context); + final keyboardHeight = mq.viewInsets.bottom; + final systemBottomPadding = mq.padding.bottom; + + return Align( + alignment: Alignment.bottomCenter, + // FIX 1: Use AnimatedPadding on the outside to correctly handle keyboard elevation smoothly. + child: AnimatedPadding( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: keyboardHeight, // Moves modal up to avoid keyboard + ), + child: ConstrainedBox( + // FIX 2: Removed minHeight constraint entirely. The Column inside uses mainAxisSize.min, + // allowing the dialog to shrink to fit content and preventing it from sitting "too high". + constraints: BoxConstraints(maxHeight: screenSize.height), + child: Material( + // Using Material to provide the background, border radius, and shadow. + color: Colors.white, + elevation: 10, // Replicating the box shadow for visual style. + shadowColor: Colors.black26, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + // Allows the content inside to scroll if keyboard reduces available space + child: Padding( + // Only apply system bottom padding for safe area/gesture bar here + padding: EdgeInsets.only(bottom: systemBottomPadding), + child: modalContent, + ), + ), + ), + ), + ), + ); + } +} + +// --- FULL OVERLAY PAINTER WITH RESIZING LOGIC --- +class ResizingSelectionOverlayPainter extends CustomPainter { final Rect rect; - SelectionOverlayPainter({required this.rect}); + final bool isResizing; + final DragHandle activeHandle; + + ResizingSelectionOverlayPainter({ + required this.rect, + required this.isResizing, + this.activeHandle = DragHandle.none, + }); @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, - ); + // 1. DIM BACKGROUND (Black overlay with hole for the selected area) + if (isResizing) { + 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); + canvas.drawPath(overlayPath, Paint()..color = Colors.black54); + } + // 2. DRAW DASHED BORDER 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) { @@ -950,21 +1358,54 @@ class SelectionOverlayPainter extends CustomPainter { } } - final Paint dotPaint = + // 3. DRAW CENTER DOT/RESIZE HANDLES + if (!isResizing) { + // Draw center dot in initial draw mode + final Paint dotPaint = + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + canvas.drawCircle( + rect.center, + 8, 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); + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + canvas.drawCircle(rect.center, 6, dotPaint); + } else { + // Draw resize handles in resizing mode + final List<Offset> corners = [ + rect.topLeft, + rect.topRight, + rect.bottomLeft, + rect.bottomRight, + ]; + + const double handleRadius = 8; + final Paint handleShadow = + Paint() + ..color = Colors.black.withOpacity(0.3) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + final Paint handleFill = Paint()..color = Colors.white; + final Paint handleBorder = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + for (final corner in corners) { + canvas.drawCircle(corner, handleRadius, handleShadow); + canvas.drawCircle(corner, handleRadius, handleFill); + canvas.drawCircle(corner, handleRadius, handleBorder); + } + } } @override - bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) => - rect != oldDelegate.rect; + bool shouldRepaint(covariant ResizingSelectionOverlayPainter oldDelegate) => + rect != oldDelegate.rect || + isResizing != oldDelegate.isResizing || + activeHandle != oldDelegate.activeHandle; } diff --git a/lib/ui/pages/image_save_page.dart b/lib/ui/pages/image_save_page.dart @@ -44,6 +44,16 @@ class ImageSavePage extends StatefulWidget { State<ImageSavePage> createState() => _ImageSavePageState(); } +// --- STATE MACHINE FOR SELECTION MODE --- +enum DragHandle { + none, + topLeft, + topRight, + bottomLeft, + bottomRight, + center, // For dragging the entire box +} + class _ImageSavePageState extends State<ImageSavePage> { // --- SERVICES --- final ImageService _imageService = ImageService(); @@ -64,12 +74,19 @@ class _ImageSavePageState extends State<ImageSavePage> { String _selectedCategory = 'Compositions'; bool _isSaving = false; - // --- DRAWING STATE --- - bool _isDrawMode = false; + // --- DRAWING/RESIZING STATE --- + bool _isDrawMode = + false; // True when initial drag is happening (to create box) + bool _isResizing = + false; // True when a selection box is visible and resizable Offset? _startPos; Offset? _currentPos; Rect? _finalSelectionRect; + // Resizing state + DragHandle _activeHandle = DragHandle.none; + Offset? _startDragLocalOffset; // Used for moving the entire rect + // FIXED: Store render size per image index final Map<int, Size> _imageRenderSizes = {}; @@ -126,13 +143,35 @@ class _ImageSavePageState extends State<ImageSavePage> { // --- ACTIONS --- void _activateSelectionMode() { setState(() { - _isDrawMode = true; + _isDrawMode = true; // Start initial drawing mode + _isResizing = false; _finalSelectionRect = null; _startPos = null; _currentPos = null; }); } + void _resetSelectionMode() { + setState(() { + _isDrawMode = false; + _isResizing = false; + _finalSelectionRect = null; + _startPos = null; + _currentPos = null; + _activeHandle = DragHandle.none; + }); + } + + void _confirmSelectionAndShowModal() { + if (_finalSelectionRect != null) { + // Exit resizing mode before showing the modal to prevent visual conflict + setState(() { + _isResizing = false; + }); + _showNoteModal(); + } + } + void _toggleTag(String tag) { setState(() { final currentTags = _tagsPerImage[_currentImageIndex]!; @@ -144,10 +183,12 @@ class _ImageSavePageState extends State<ImageSavePage> { }); } - // --- DRAWING GESTURES (FIXED LOGIC) --- + // --- DRAWING/RESIZING GESTURES (UPDATED LOGIC) --- + final double _handleSize = + 25.0; // The size of the touch area for resizing handles + // Helper: Convert global screen touch to local image coordinates Offset? _getLocalPosition(Offset globalPosition) { - // Get the key for the currently visible image final currentKey = _imageKeys[_currentImageIndex]; final RenderBox? box = currentKey.currentContext?.findRenderObject() as RenderBox?; @@ -159,13 +200,14 @@ class _ImageSavePageState extends State<ImageSavePage> { // Convert global to local final local = box.globalToLocal(globalPosition); - // Clamp coordinates to ensure we don't draw outside the image + // Clamp coordinates to ensure we don't draw/drag outside the image final dx = local.dx.clamp(0.0, box.size.width); final dy = local.dy.clamp(0.0, box.size.height); return Offset(dx, dy); } + // --- INITIAL DRAWING HANDLERS --- void _onPanStart(DragStartDetails details) { if (!_isDrawMode) return; @@ -192,113 +234,286 @@ class _ImageSavePageState extends State<ImageSavePage> { void _onPanEnd(DragEndDetails details) { if (!_isDrawMode || _startPos == null || _currentPos == null) return; - // Create the rect from the corrected local positions - final rect = Rect.fromPoints(_startPos!, _currentPos!); - _finalSelectionRect = rect; + // Create the rect from the corrected local positions, normalizing points + final rect = Rect.fromPoints(_startPos!, _currentPos!).normalize(); + + // Check if the selected area is too small + if (rect.width < 10 || rect.height < 10) { + _resetSelectionMode(); + return; + } setState(() { _isDrawMode = false; + _isResizing = true; // Enter resizing/confirming mode + _finalSelectionRect = rect; _startPos = null; _currentPos = null; }); + } + + // --- RESIZING HANDLERS --- + DragHandle _getDragHandle(Offset pos) { + if (_finalSelectionRect == null) return DragHandle.none; + + final rect = _finalSelectionRect!; + // final center = rect.center; // Not used but helpful for context + // final top = rect.top; + // final bottom = rect.bottom; + // final left = rect.left; + // final right = rect.right; + + // Check corners + if (Rect.fromCircle( + center: rect.topLeft, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.topLeft; + } else if (Rect.fromCircle( + center: rect.topRight, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.topRight; + } else if (Rect.fromCircle( + center: rect.bottomLeft, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.bottomLeft; + } else if (Rect.fromCircle( + center: rect.bottomRight, + radius: _handleSize, + ).contains(pos)) { + return DragHandle.bottomRight; + } + // Check if dragging the whole box (center) + else if (rect.contains(pos)) { + // Only allow center drag if we are not actively drawing (i.e. we are in resizing mode) + return DragHandle.center; + } - _showNoteModal(); + return DragHandle.none; } - // --- ADD NOTE MODAL --- + void _onResizeStart(DragStartDetails details) { + if (!_isResizing || _finalSelectionRect == null) return; + + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + + final handle = _getDragHandle(pos); + if (handle != DragHandle.none) { + setState(() { + _activeHandle = handle; + // Calculate offset for moving the entire rect, not for resizing + if (handle == DragHandle.center) { + _startDragLocalOffset = pos - _finalSelectionRect!.topLeft; + } + }); + } + } + + void _onResizeUpdate(DragUpdateDetails details) { + if (!_isResizing || + _finalSelectionRect == null || + _activeHandle == DragHandle.none) + return; + + final pos = _getLocalPosition(details.globalPosition); + if (pos == null) return; + + setState(() { + Rect newRect = _finalSelectionRect!; + final newPoint = pos; + + switch (_activeHandle) { + case DragHandle.topLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newPoint.dy, + newRect.right, + newRect.bottom, + ); + break; + case DragHandle.topRight: + newRect = Rect.fromLTRB( + newRect.left, + newPoint.dy, + newPoint.dx, + newRect.bottom, + ); + break; + case DragHandle.bottomLeft: + newRect = Rect.fromLTRB( + newPoint.dx, + newRect.top, + newRect.right, + newPoint.dy, + ); + break; + case DragHandle.bottomRight: + newRect = Rect.fromLTRB( + newRect.left, + newRect.top, + newPoint.dx, + newPoint.dy, + ); + break; + case DragHandle.center: + if (_startDragLocalOffset != null) { + final newTopLeft = newPoint - _startDragLocalOffset!; + newRect = Rect.fromLTWH( + newTopLeft.dx, + newTopLeft.dy, + newRect.width, + newRect.height, + ); + } + break; + case DragHandle.none: + return; + } + + // Clamp the final rectangle to the image boundaries (0,0 to width, height) + final imageSize = _imageRenderSizes[_currentImageIndex]; + if (imageSize != null) { + final clampedLeft = newRect.left.clamp(0.0, imageSize.width); + final clampedTop = newRect.top.clamp(0.0, imageSize.height); + final clampedRight = newRect.right.clamp(0.0, imageSize.width); + final clampedBottom = newRect.bottom.clamp(0.0, imageSize.height); + + newRect = + Rect.fromLTRB( + clampedLeft, + clampedTop, + clampedRight, + clampedBottom, + ).normalize(); + } else { + newRect = newRect.normalize(); + } + + // Ensure min size + if (newRect.width > 10 && newRect.height > 10) { + _finalSelectionRect = newRect; + } + }); + } + + void _onResizeEnd(DragEndDetails details) { + if (!_isResizing) return; + setState(() { + _activeHandle = DragHandle.none; + _startDragLocalOffset = null; + }); + } + + // --- ADD NOTE MODAL (MODIFIED FOR HALF PAGE OVERLAY) --- void _showNoteModal() { _commentController.clear(); + // Ensure we have a valid selection to proceed + if (_finalSelectionRect == null) return; + showModalBottomSheet( context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, + isScrollControlled: true, // Crucial for custom height/overlay + backgroundColor: + Colors.transparent, // Crucial to allow the custom overlay to show 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)), + final mediaQuery = MediaQuery.of(context); + + // The actual content of the note modal (the form) + // [MODIFIED] Removed the redundant outer SingleChildScrollView. + // Scrolling is handled in NoteModalOverlay. + final modalContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 20, left: 20, right: 20), + child: Text( + "Add Note", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), ), - 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), + const SizedBox(height: 16), + // Category Dropdown + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: DropdownButtonFormField<String>( + value: _selectedCategory, + decoration: InputDecoration( + labelText: "Category", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - const SizedBox(height: 16), - // Category Dropdown - DropdownButtonFormField<String>( - value: _selectedCategory, - decoration: InputDecoration( - labelText: "Category", - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - items: - _categories - .map( - (c) => DropdownMenuItem(value: c, child: Text(c)), - ) - .toList(), - onChanged: (v) => setState(() => _selectedCategory = v!), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - const SizedBox(height: 12), - // Note Text - TextField( - controller: _commentController, - autofocus: true, - maxLines: 2, - decoration: InputDecoration( - hintText: "Enter details...", - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + ), + items: + _categories + .map((c) => DropdownMenuItem(value: c, child: Text(c))) + .toList(), + onChanged: (v) => setState(() => _selectedCategory = v!), + ), + ), + const SizedBox(height: 12), + // Note Text + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextField( + controller: _commentController, + autofocus: true, + maxLines: 2, + decoration: InputDecoration( + hintText: "Enter details...", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - height: 50, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: () { - _addTempNote(); - Navigator.pop(context); - }, - child: const Text( - "Save Note", - style: TextStyle(fontWeight: FontWeight.bold), - ), + ), + ), + ), + const SizedBox(height: 16), + // Save Button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), ), - ], + onPressed: () { + _addTempNote(); + Navigator.pop(context); + }, + child: const Text( + "Save Note", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), ), ), - ), + const SizedBox(height: 20), + ], + ); + + // Wrap the content with the custom overlay + return NoteModalOverlay( + modalContent: modalContent, + screenSize: mediaQuery.size, ); }, ); } void _addTempNote() { - // FIXED: Use the stored render size for the current image index + // Use the stored render size for the current image index final imageSize = _imageRenderSizes[_currentImageIndex]; if (_finalSelectionRect != null && @@ -321,6 +536,7 @@ class _ImageSavePageState extends State<ImageSavePage> { setState(() { _notesPerImage[_currentImageIndex]?.add(newNote); + _finalSelectionRect = null; // Clear selection after saving note }); } } @@ -359,6 +575,7 @@ class _ImageSavePageState extends State<ImageSavePage> { } if (mounted) { + // Find the most suitable ScaffoldMessengerState ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('All images saved successfully!'), @@ -392,7 +609,12 @@ class _ImageSavePageState extends State<ImageSavePage> { ? '$parentName / ${widget.projectName}' : widget.projectName; + // Determine if user can pan/zoom the image carousel + final isPageLocked = _isDrawMode || _isResizing; + return Scaffold( + // FIX: Prevents the main screen/image from pushing up when the keyboard opens. + resizeToAvoidBottomInset: false, backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, @@ -413,6 +635,23 @@ class _ImageSavePageState extends State<ImageSavePage> { fontWeight: FontWeight.w600, ), ), + actions: [ + // CONFIRM SELECTION BUTTON (Visible only in resizing mode) + if (_isResizing && _finalSelectionRect != null) + IconButton( + icon: const Icon( + Icons.check_circle_outline, + color: Color(0xFF7C4DFF), + ), + onPressed: _confirmSelectionAndShowModal, + ), + // CANCEL SELECTION BUTTON (Visible only in drawing/resizing mode) + if (_isDrawMode || _isResizing) + IconButton( + icon: const Icon(Icons.close, color: Colors.black), + onPressed: _resetSelectionMode, + ), + ], ), body: Column( children: [ @@ -432,30 +671,47 @@ class _ImageSavePageState extends State<ImageSavePage> { children: [ PageView.builder( controller: _pageController, + // Disable page view scrolling if a selection process is active + physics: + isPageLocked + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(), itemCount: widget.imagePaths.length, onPageChanged: (index) { setState(() { _currentImageIndex = index; - _isDrawMode = false; - _finalSelectionRect = null; + _resetSelectionMode(); // Reset selection mode on page change }); }, itemBuilder: (context, index) { return LayoutBuilder( builder: (context, constraints) { + // Determine the gesture handler based on the mode + final onPanStartHandler = + _isDrawMode + ? _onPanStart + : (_isResizing ? _onResizeStart : null); + final onPanUpdateHandler = + _isDrawMode + ? _onPanUpdate + : (_isResizing ? _onResizeUpdate : null); + final onPanEndHandler = + _isDrawMode + ? _onPanEnd + : (_isResizing ? _onResizeEnd : null); + return Stack( fit: StackFit.expand, children: [ InteractiveViewer( - panEnabled: !_isDrawMode, - scaleEnabled: !_isDrawMode, + // Disable pan/scale if selection or resizing is active + panEnabled: !isPageLocked, + scaleEnabled: !isPageLocked, child: Center( child: GestureDetector( - onPanStart: - _isDrawMode ? _onPanStart : null, - onPanUpdate: - _isDrawMode ? _onPanUpdate : null, - onPanEnd: _isDrawMode ? _onPanEnd : null, + onPanStart: onPanStartHandler, + onPanUpdate: onPanUpdateHandler, + onPanEnd: onPanEndHandler, child: Stack( children: [ // THE IMAGE WITH UNIQUE KEY @@ -465,7 +721,7 @@ class _ImageSavePageState extends State<ImageSavePage> { fit: BoxFit.contain, width: double.infinity, ), - // DRAWING OVERLAY (Only if drawing on THIS page) + // DRAWING OVERLAY (if in drawing mode) if (_isDrawMode && index == _currentImageIndex && _startPos != null && @@ -478,6 +734,23 @@ class _ImageSavePageState extends State<ImageSavePage> { _startPos!, _currentPos!, ), + isResizing: false, + ), + ), + ), + // FINAL SELECTION RECT (if in resizing mode) + if (_isResizing && + index == _currentImageIndex && + _finalSelectionRect != null) + Positioned.fill( + child: CustomPaint( + painter: + SelectionOverlayPainter( + rect: + _finalSelectionRect!, + isResizing: true, + activeHandle: + _activeHandle, ), ), ), @@ -486,119 +759,166 @@ class _ImageSavePageState extends State<ImageSavePage> { ), ), ), - // EXISTING NOTE INDICATORS (Dots) - ...(_notesPerImage[index] ?? []).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, + // EXISTING NOTE INDICATORS (Dots) - visible only if no selection is active + if (!isPageLocked) + ...(_notesPerImage[index] ?? []).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), ), - blurRadius: 4, - offset: const Offset(0, 1), + ], + border: Border.all( + color: const Color(0xFF7C4DFF), + width: 2, + ), + ), + ), + ); + }).toList(), + // PAGE DOTS + if (widget.imagePaths.length > 1 && + !isPageLocked) + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: List.generate( + widget.imagePaths.length, + (index) => Container( + margin: const EdgeInsets.symmetric( + horizontal: 4, + ), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + _currentImageIndex == index + ? Colors.blue + : Colors.white.withOpacity( + 0.5, + ), ), - ], - border: Border.all( - color: const Color(0xFF7C4DFF), - width: 2, ), ), ), - ); - }).toList(), + ), + // NOTES BUTTON (Visible only when not drawing/resizing) + if (!isPageLocked) + Positioned( + bottom: 24, + right: 12, + child: ElevatedButton.icon( + onPressed: _activateSelectionMode, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 12, + ), + ), + ), + icon: const Icon( + Icons.assignment_outlined, + size: 18, + ), + label: const Text( + "Notes", + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + // INSTRUCTION OVERLAY (for initial drawing) + 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 on image to select area", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + ), + // INSTRUCTION OVERLAY (for resizing) + if (_isResizing && + _finalSelectionRect != null && + _activeHandle == DragHandle.none) + 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( + "Adjust area or tap Checkmark to confirm", + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + ), ], ); }, ); }, ), - // PAGE DOTS - if (widget.imagePaths.length > 1) - Positioned( - bottom: 12, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - widget.imagePaths.length, - (index) => Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - _currentImageIndex == index - ? Colors.blue - : Colors.white.withOpacity(0.5), - ), - ), - ), - ), - ), - // NOTES BUTTON - Positioned( - bottom: 24, - right: 12, - child: ElevatedButton.icon( - onPressed: _activateSelectionMode, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.assignment_outlined, size: 18), - label: const Text( - "Notes", - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), - ), - // INSTRUCTION OVERLAY - 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 on image to select area", - style: TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ), - ), - ), ], ), ), @@ -727,25 +1047,106 @@ class _ImageSavePageState extends State<ImageSavePage> { } } -// --- OVERLAY PAINTER --- +// ----------------------------------------------------------------------------- +// --- NEW HELPER CLASSES FOR CUSTOM HALF-PAGE MODAL OVERLAY --- +// ----------------------------------------------------------------------------- + +class NoteModalOverlay extends StatelessWidget { + final Widget modalContent; + final Size screenSize; + + const NoteModalOverlay({ + Key? key, + required this.modalContent, + required this.screenSize, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // The target initial height of the bottom sheet (half the screen height is no longer the minimum) + final mq = MediaQuery.of(context); + final keyboardHeight = mq.viewInsets.bottom; + final systemBottomPadding = mq.padding.bottom; + + return Align( + alignment: Alignment.bottomCenter, + // FIX 1: Use AnimatedPadding for smooth keyboard elevation. + child: AnimatedPadding( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: keyboardHeight, // Moves modal up to avoid keyboard + ), + child: ConstrainedBox( + // FIX 2: Remove fixed minHeight to let the modal shrink to fit its content, + // addressing the "gets up too high" issue. + constraints: BoxConstraints(maxHeight: screenSize.height), + child: Material( + // Using Material to provide the background, border radius, and shadow. + color: Colors.white, + elevation: + 10, // Replicating the box shadow of the old container for visual style. + shadowColor: Colors.black26, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + // Allows scrolling if content + keyboard height exceed screen height + child: Padding( + // Add system bottom padding to respect the safe area/gesture bar + padding: EdgeInsets.only(bottom: systemBottomPadding), + child: modalContent, + ), + ), + ), + ), + ), + ); + } +} + +// [NoteModalPainter class removed as requested to remove the dimming shadow] + +// --- EXTENSION TO NORMALIZE RECT --- +extension on Rect { + Rect normalize() { + return Rect.fromLTRB( + left < right ? left : right, + top < bottom ? top : bottom, + left > right ? left : right, + top > bottom ? top : bottom, + ); + } +} + +// --- OVERLAY PAINTER (KEPT FOR MAIN IMAGE SELECTION HIGHLIGHT) --- class SelectionOverlayPainter extends CustomPainter { final Rect rect; + final bool isResizing; + final DragHandle activeHandle; - SelectionOverlayPainter({required this.rect}); + SelectionOverlayPainter({ + required this.rect, + required this.isResizing, + this.activeHandle = DragHandle.none, + }); @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, - ); + // 1. DIM BACKGROUND (Black overlay with hole for the selected area) + if (isResizing) { + 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); + canvas.drawPath(overlayPath, Paint()..color = Colors.black54); + } + // 2. DRAW DASHED BORDER final Paint borderPaint = Paint() ..color = const Color(0xFF448AFF) @@ -767,22 +1168,57 @@ class SelectionOverlayPainter extends CustomPainter { } } - final Paint dotPaint = + // 3. DRAW CENTER DOT (Only needed in drawing mode, the app bar button replaces the functionality in resizing mode) + if (!isResizing) { + final Paint dotPaint = + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + canvas.drawCircle( + rect.center, + 8, 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); + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + canvas.drawCircle(rect.center, 6, dotPaint); + } + + // 4. DRAW RESIZE HANDLES (Only in resizing mode) + if (isResizing) { + final List<Offset> corners = [ + rect.topLeft, + rect.topRight, + rect.bottomLeft, + rect.bottomRight, + ]; + + final Paint handleShadow = + Paint() + ..color = Colors.black.withOpacity(0.3) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + + final Paint handleFill = Paint()..color = Colors.white; + final Paint handleBorder = + Paint() + ..color = const Color(0xFF448AFF) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + const double handleRadius = 8; + + for (final corner in corners) { + canvas.drawCircle(corner, handleRadius, handleShadow); + canvas.drawCircle(corner, handleRadius, handleFill); + canvas.drawCircle(corner, handleRadius, handleBorder); + } + } } @override bool shouldRepaint(covariant SelectionOverlayPainter oldDelegate) => - rect != oldDelegate.rect; + rect != oldDelegate.rect || + isResizing != oldDelegate.isResizing || + activeHandle != oldDelegate.activeHandle; }