creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
lib/ui/widgets/image_context_menu.dart (9050B)
1import 'dart:math' as math;
2import 'package:flutter/material.dart';
3import 'package:flutter_svg/flutter_svg.dart';
4import 'package:creekui/data/models/image_model.dart';
5import 'package:creekui/utils/image_actions_helper.dart';
6import 'package:creekui/ui/styles/variables.dart';
7
8class ImageContextMenu extends StatelessWidget {
9 final ImageModel image;
10 final Widget child;
11 final VoidCallback onImageDeleted;
12
13 const ImageContextMenu({
14 super.key,
15 required this.image,
16 required this.child,
17 required this.onImageDeleted,
18 });
19
20 void _showRadialMenu(BuildContext context, Offset globalPosition) {
21 Navigator.of(context).push(
22 PageRouteBuilder(
23 opaque: false,
24 barrierDismissible: true,
25 barrierColor: Colors.black12, // Subtle dim
26 transitionDuration: const Duration(milliseconds: 200),
27 pageBuilder: (context, animation, secondaryAnimation) {
28 return FadeTransition(
29 opacity: animation,
30 child: _RadialMenuOverlay(
31 center: globalPosition,
32 image: image,
33 onImageDeleted: onImageDeleted,
34 ),
35 );
36 },
37 ),
38 );
39 }
40
41 @override
42 Widget build(BuildContext context) {
43 return GestureDetector(
44 // Get exact touch position
45 onLongPressStart:
46 (details) => _showRadialMenu(context, details.globalPosition),
47 child: child,
48 );
49 }
50}
51
52class _RadialMenuOverlay extends StatefulWidget {
53 final Offset center;
54 final ImageModel image;
55 final VoidCallback onImageDeleted;
56
57 const _RadialMenuOverlay({
58 required this.center,
59 required this.image,
60 required this.onImageDeleted,
61 });
62
63 @override
64 State<_RadialMenuOverlay> createState() => _RadialMenuOverlayState();
65}
66
67class _RadialMenuOverlayState extends State<_RadialMenuOverlay>
68 with SingleTickerProviderStateMixin {
69 late AnimationController _controller;
70 late Animation<double> _scaleAnimation;
71
72 // Configuration
73 final double radius = 120.0;
74 final double buttonSize = 56.0;
75 final double arcSpan = 150.0;
76
77 @override
78 void initState() {
79 super.initState();
80 _controller = AnimationController(
81 duration: const Duration(milliseconds: 350),
82 vsync: this,
83 );
84
85 _scaleAnimation = CurvedAnimation(
86 parent: _controller,
87 curve: Curves.easeOutBack,
88 );
89
90 _controller.forward();
91 }
92
93 @override
94 void dispose() {
95 _controller.dispose();
96 super.dispose();
97 }
98
99 // Closes menu with animation
100 void _closeAnimated() {
101 _controller.reverse().then((_) {
102 if (mounted) Navigator.of(context).pop();
103 });
104 }
105
106 // ACTIONS
107
108 Future<void> _shareImage() async {
109 Navigator.of(context).pop();
110 await ImageActionsHelper.shareImage(context, widget.image.filePath);
111 }
112
113 void _sendToFiles() {
114 Navigator.of(context).pop();
115 ImageActionsHelper.sendToFiles(context, widget.image.filePath);
116 }
117
118 Future<void> _renameImage() async {
119 // Hide buttons to not obstruct dialog
120 _controller.reverse();
121
122 await ImageActionsHelper.renameImage(
123 context,
124 widget.image,
125 widget.onImageDeleted, // Triggers refresh in parent
126 );
127
128 if (mounted) Navigator.of(context).pop();
129 }
130
131 Future<void> _deleteImage() async {
132 // Hide buttons
133 _controller.reverse();
134
135 await ImageActionsHelper.deleteImage(
136 context,
137 widget.image,
138 widget.onImageDeleted,
139 );
140
141 if (mounted) Navigator.of(context).pop();
142 }
143
144 void _notImplemented() {
145 ScaffoldMessenger.of(context).showSnackBar(
146 SnackBar(
147 content: const Text(
148 "Coming soon",
149 style: TextStyle(fontFamily: 'GeneralSans'),
150 ),
151 backgroundColor: Variables.textPrimary,
152 behavior: SnackBarBehavior.floating,
153 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
154 margin: const EdgeInsets.all(20),
155 duration: const Duration(seconds: 1),
156 ),
157 );
158 }
159
160 @override
161 Widget build(BuildContext context) {
162 final screenSize = MediaQuery.of(context).size;
163
164 // 1. Determine Horizontal Side
165 final bool isRightSide = widget.center.dx > screenSize.width / 2;
166 double baseAngle = isRightSide ? 180 : 0;
167
168 // 2. Determine Vertical clipping adjustments
169 double topBoundary = 120;
170 double bottomBoundary = screenSize.height - 120;
171
172 double rotationOffset = 0;
173 if (widget.center.dy < topBoundary) {
174 // Too close to TOP -> Rotate DOWN
175 rotationOffset = isRightSide ? -45 : 45;
176 } else if (widget.center.dy > bottomBoundary) {
177 // Too close to BOTTOM -> Rotate UP
178 rotationOffset = isRightSide ? 45 : -45;
179 }
180
181 baseAngle += rotationOffset;
182
183 // 3. Define Buttons
184 final buttons = [
185 _MenuButtonData(
186 svgPath: 'assets/icons/share_icon.svg',
187 onTap: _shareImage,
188 ),
189 _MenuButtonData(
190 svgPath: 'assets/icons/files_icon.svg',
191 onTap: _sendToFiles,
192 ),
193 _MenuButtonData(
194 icon: Icons.drive_file_rename_outline,
195 onTap: _renameImage,
196 ),
197 _MenuButtonData(
198 svgPath: 'assets/icons/trash_icon.svg',
199 onTap: _deleteImage,
200 isDestructive: true,
201 ),
202 _MenuButtonData(
203 svgPath: 'assets/icons/ai-search.svg',
204 onTap: _notImplemented,
205 ),
206 ];
207
208 return Stack(
209 children: [
210 // Backdrop
211 Positioned.fill(
212 child: GestureDetector(
213 onTap: _closeAnimated,
214 behavior: HitTestBehavior.opaque,
215 child: Container(color: Colors.transparent),
216 ),
217 ),
218
219 // Buttons
220 ...List.generate(buttons.length, (index) {
221 final step = arcSpan / (buttons.length - 1);
222 final startAngle = baseAngle - (arcSpan / 2);
223 final angleDeg = startAngle + (step * index);
224
225 return _buildAnimatedButton(angleDeg: angleDeg, data: buttons[index]);
226 }),
227 ],
228 );
229 }
230
231 Widget _buildAnimatedButton({
232 required double angleDeg,
233 required _MenuButtonData data,
234 }) {
235 final rad = angleDeg * (math.pi / 180);
236
237 return AnimatedBuilder(
238 animation: _scaleAnimation,
239 builder: (context, child) {
240 final r = radius * _scaleAnimation.value;
241 final dx = widget.center.dx + (r * math.cos(rad)) - (buttonSize / 2);
242 final dy = widget.center.dy + (r * math.sin(rad)) - (buttonSize / 2);
243
244 return Positioned(
245 left: dx,
246 top: dy,
247 child: Transform.scale(
248 scale: _scaleAnimation.value,
249 child: _FloatingCircleButton(size: buttonSize, data: data),
250 ),
251 );
252 },
253 );
254 }
255}
256
257// Data Helper
258class _MenuButtonData {
259 final IconData? icon;
260 final String? svgPath;
261 final VoidCallback onTap;
262 final bool isDestructive;
263
264 _MenuButtonData({
265 this.icon,
266 this.svgPath,
267 required this.onTap,
268 this.isDestructive = false,
269 });
270}
271
272class _FloatingCircleButton extends StatefulWidget {
273 final double size;
274 final _MenuButtonData data;
275
276 const _FloatingCircleButton({required this.size, required this.data});
277
278 @override
279 State<_FloatingCircleButton> createState() => _FloatingCircleButtonState();
280}
281
282class _FloatingCircleButtonState extends State<_FloatingCircleButton> {
283 bool _isPressed = false;
284
285 @override
286 Widget build(BuildContext context) {
287 final Color normalIconColor =
288 widget.data.isDestructive ? Colors.red : Variables.textPrimary;
289 final Color activeIconColor = Colors.white;
290
291 final Color normalBgColor = Colors.white;
292 final Color activeBgColor = const Color(0xFF7C4DFF);
293
294 final currentColor = _isPressed ? activeIconColor : normalIconColor;
295 final currentBg = _isPressed ? activeBgColor : normalBgColor;
296 final scale = _isPressed ? 1.2 : 1.0;
297
298 return GestureDetector(
299 onTapDown: (_) => setState(() => _isPressed = true),
300 onTapUp: (_) => setState(() => _isPressed = false),
301 onTapCancel: () => setState(() => _isPressed = false),
302 onTap: widget.data.onTap,
303 child: AnimatedContainer(
304 duration: const Duration(milliseconds: 150),
305 curve: Curves.easeOut,
306 width: widget.size,
307 height: widget.size,
308 transform: Matrix4.identity()..scale(scale),
309 transformAlignment: Alignment.center,
310 decoration: BoxDecoration(
311 color: currentBg,
312 shape: BoxShape.circle,
313 boxShadow: [
314 BoxShadow(
315 color: Colors.black.withOpacity(0.15),
316 blurRadius: 10,
317 offset: const Offset(0, 4),
318 ),
319 ],
320 ),
321 padding: const EdgeInsets.all(14),
322 child:
323 widget.data.svgPath != null
324 ? SvgPicture.asset(
325 widget.data.svgPath!,
326 width: 18,
327 height: 18,
328 colorFilter: ColorFilter.mode(currentColor, BlendMode.srcIn),
329 )
330 : Icon(widget.data.icon, color: currentColor, size: 24),
331 ),
332 );
333 }
334}