creek

The AI Image Editor of 2030

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}