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:
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;
}