commit 90bbbe9f83839900c41a582d4b5baa65384e76df
parent f8d5751182b93faeb8ba2accdc3b339a6c0eb42d
Author: maydayv7 <maydayv7@gmail.com>
Date: Sun, 30 Nov 2025 23:37:45 +0530
Add long-press image context menu
Diffstat:
15 files changed, 608 insertions(+), 178 deletions(-)
diff --git a/assets/icons/ai-search.svg b/assets/icons/ai-search.svg
@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.1665 1.66635C9.38642 1.66635 9.604 1.67788 9.81917 1.69646C9.53517 2.1952 9.33484 2.74761 9.2365 3.33465C9.21317 3.33437 9.18984 3.33302 9.1665 3.33302C5.9437 3.33302 3.33335 5.94359 3.33317 9.1664C3.33317 12.3893 5.94359 14.9997 9.1665 14.9997C10.7462 14.9997 12.1788 14.3723 13.229 13.3534L13.3535 13.2289C14.3723 12.1787 14.9998 10.7459 14.9998 9.1664C14.9998 9.14281 14.9977 9.11906 14.9974 9.09556C15.5847 8.99731 16.1367 8.7969 16.6356 8.5129C16.6542 8.72823 16.6665 8.94623 16.6665 9.1664C16.6665 10.9362 16.0525 12.5641 15.0258 13.8474L18.5944 17.4159L17.416 18.5943L13.8475 15.0257C12.5643 16.0525 10.9365 16.6664 9.1665 16.6664C5.0265 16.6664 1.6665 13.3064 1.6665 9.1664C1.66668 5.0265 5.02661 1.66635 9.1665 1.66635ZM13.7743 1.09913C13.9214 0.744306 14.4117 0.744296 14.5588 1.09913L14.7695 1.60857C15.1295 2.47756 15.8012 3.1715 16.6453 3.54705L17.2435 3.81316C17.5853 3.96551 17.5853 4.46318 17.2435 4.61557L16.6103 4.89715C15.7873 5.26321 15.1277 5.93257 14.7614 6.77296L14.5555 7.24416C14.4052 7.58927 13.9279 7.58927 13.7775 7.24416L13.5724 6.77296C13.2062 5.93244 12.5458 5.26325 11.7227 4.89715L11.0895 4.61557C10.7477 4.46319 10.7477 3.9655 11.0895 3.81316L11.6877 3.54705C12.5319 3.1715 13.2036 2.47758 13.5635 1.60857L13.7743 1.09913Z" fill="black"/>
+</svg>
diff --git a/assets/icons/share_icon.svg b/assets/icons/share_icon.svg
@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.9333 14.1857L7.43425 12.277C6.82596 12.9271 5.96035 13.3333 4.99984 13.3333C3.15889 13.3333 1.6665 11.8409 1.6665 9.99999C1.6665 8.15904 3.15889 6.66666 4.99984 6.66666C5.9603 6.66666 6.82588 7.07287 7.43415 7.72287L10.9333 5.81426C10.8679 5.55366 10.8332 5.28088 10.8332 4.99999C10.8332 3.15904 12.3256 1.66666 14.1665 1.66666C16.0074 1.66666 17.4998 3.15904 17.4998 4.99999C17.4998 6.84094 16.0074 8.33332 14.1665 8.33332C13.206 8.33332 12.3404 7.92708 11.7321 7.27704L8.23298 9.18566C8.29842 9.44624 8.33317 9.71907 8.33317 9.99999C8.33317 10.2809 8.29843 10.5537 8.23301 10.8142L11.7322 12.7229C12.3404 12.0729 13.206 11.6667 14.1665 11.6667C16.0074 11.6667 17.4998 13.1591 17.4998 15C17.4998 16.8409 16.0074 18.3333 14.1665 18.3333C12.3256 18.3333 10.8332 16.8409 10.8332 15C10.8332 14.7191 10.8679 14.4462 10.9333 14.1857ZM4.99984 11.6667C5.92031 11.6667 6.6665 10.9205 6.6665 9.99999C6.6665 9.07949 5.92031 8.33332 4.99984 8.33332C4.07936 8.33332 3.33317 9.07949 3.33317 9.99999C3.33317 10.9205 4.07936 11.6667 4.99984 11.6667ZM14.1665 6.66666C15.087 6.66666 15.8332 5.92046 15.8332 4.99999C15.8332 4.07951 15.087 3.33332 14.1665 3.33332C13.246 3.33332 12.4998 4.07951 12.4998 4.99999C12.4998 5.92046 13.246 6.66666 14.1665 6.66666ZM14.1665 16.6667C15.087 16.6667 15.8332 15.9205 15.8332 15C15.8332 14.0795 15.087 13.3333 14.1665 13.3333C13.246 13.3333 12.4998 14.0795 12.4998 15C12.4998 15.9205 13.246 16.6667 14.1665 16.6667Z" fill="black"/>
+</svg>
diff --git a/assets/icons/trash_icon.svg b/assets/icons/trash_icon.svg
@@ -0,0 +1,3 @@
+<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.16667 1.66667V0H12.5V1.66667H16.6667V3.33333H15V15.8333C15 16.2936 14.6269 16.6667 14.1667 16.6667H2.5C2.03977 16.6667 1.66667 16.2936 1.66667 15.8333V3.33333H0V1.66667H4.16667ZM3.33333 3.33333V15H13.3333V3.33333H3.33333ZM5.83333 5.83333H7.5V12.5H5.83333V5.83333ZM9.16667 5.83333H10.8333V12.5H9.16667V5.83333Z" fill="black"/>
+</svg>
diff --git a/lib/data/repos/image_repo.dart b/lib/data/repos/image_repo.dart
@@ -48,6 +48,16 @@ class ImageRepo {
);
}
+ Future<void> updateName(String id, String newName) async {
+ final db = await AppDatabase.db;
+ await db.update(
+ 'images',
+ {'name': newName},
+ where: 'id = ?',
+ whereArgs: [id],
+ );
+ }
+
Future<List<ImageModel>> getPendingImages() async {
final db = await AppDatabase.db;
final res = await db.query(
diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart
@@ -100,6 +100,10 @@ class ImageService {
AnalysisQueueManager().processQueue();
}
+ Future<void> renameImage(String id, String newName) async {
+ await _repo.updateName(id, newName);
+ }
+
Future<void> updateAnalysis(String id, Map<String, dynamic> analysis) async {
final jsonString = jsonEncode(analysis);
await _repo.updateAnalysis(id, jsonString);
diff --git a/lib/ui/pages/project_board_page.dart b/lib/ui/pages/project_board_page.dart
@@ -13,6 +13,7 @@ import 'project_tag_page.dart';
import 'project_board_page_alternate.dart';
import 'image_save_page.dart';
import 'image_details_page.dart';
+import '../widgets/image_context_menu.dart';
class ProjectBoardPage extends StatefulWidget {
final int projectId;
@@ -98,25 +99,6 @@ class _ProjectBoardPageState extends State<ProjectBoardPage> {
}
}
- // void _categorizeImages(List<ImageModel> images) {
- // _categorizedImages.clear();
- // for (var img in images) {
- // if (img.tags.isEmpty) {
- // if (!_categorizedImages.containsKey('Uncategorized')) {
- // _categorizedImages['Uncategorized'] = [];
- // }
- // _categorizedImages['Uncategorized']!.add(img);
- // } else {
- // for (var tag in img.tags) {
- // if (!_categorizedImages.containsKey(tag)) {
- // _categorizedImages[tag] = [];
- // }
- // _categorizedImages[tag]!.add(img);
- // }
- // }
- // }
- // }
-
void _onProjectChanged(ProjectModel newProject) {
if (newProject.id != _currentProject?.id) {
setState(() {
@@ -369,48 +351,52 @@ class _ProjectBoardPageState extends State<ProjectBoardPage> {
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, imgIndex) {
final image = images[imgIndex];
- return GestureDetector(
- onTap: () {
- if (_currentProject?.id != null) {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder:
- (_) => ImageDetailsPage(
- imagePath: image.filePath,
- imageId: image.id,
- projectId: _currentProject!.id!,
- ),
- ),
- ).then((_) => _loadImagesForSelected());
- }
- },
- child: Container(
- width: 120,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(12),
- color: Variables.surfaceSubtle,
- border: Border.all(color: Variables.borderSubtle),
- ),
- // FIX: Explicitly clip image to border radius
- child: ClipRRect(
- borderRadius: BorderRadius.circular(12),
- child: Stack(
- fit: StackFit.expand,
- children: [
- Image.file(
- File(image.filePath),
- fit: BoxFit.cover,
- errorBuilder:
- (_, __, ___) => Container(
- color: Variables.surfaceSubtle,
- child: const Icon(
- Icons.broken_image,
- color: Variables.textDisabled,
- ),
+ return ImageContextMenu(
+ image: image,
+ onImageDeleted: () => _loadImagesForSelected(),
+ child: GestureDetector(
+ onTap: () {
+ if (_currentProject?.id != null) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder:
+ (_) => ImageDetailsPage(
+ imagePath: image.filePath,
+ imageId: image.id,
+ projectId: _currentProject!.id!,
),
),
- ],
+ ).then((_) => _loadImagesForSelected());
+ }
+ },
+ child: Container(
+ width: 120,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Variables.surfaceSubtle,
+ border: Border.all(color: Variables.borderSubtle),
+ ),
+ // FIX: Explicitly clip image to border radius
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.file(
+ File(image.filePath),
+ fit: BoxFit.cover,
+ errorBuilder:
+ (_, __, ___) => Container(
+ color: Variables.surfaceSubtle,
+ child: const Icon(
+ Icons.broken_image,
+ color: Variables.textDisabled,
+ ),
+ ),
+ ),
+ ],
+ ),
),
),
),
diff --git a/lib/ui/pages/project_board_page_alternate.dart b/lib/ui/pages/project_board_page_alternate.dart
@@ -4,6 +4,7 @@ import 'package:adobe/ui/styles/variables.dart';
import '../../data/models/image_model.dart';
import '../../data/repos/image_repo.dart';
import 'image_details_page.dart';
+import '../widgets/image_context_menu.dart';
class ProjectBoardPageAlternate extends StatefulWidget {
final int projectId;
@@ -313,91 +314,95 @@ class ProjectBoardPageAlternateState extends State<ProjectBoardPageAlternate> {
}
Widget _buildImageItem(ImageModel image, {required int index}) {
- return GestureDetector(
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder:
- (_) => ImageDetailsPage(
- imagePath: image.filePath,
- imageId: image.id,
- projectId: widget.projectId,
- ),
- ),
- );
- },
- child: Container(
- height: (index % 3 == 0) ? 240 : 180,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(16),
- color: Variables.surfaceSubtle,
- ),
- clipBehavior: Clip.antiAlias,
- child: Stack(
- fit: StackFit.expand,
- children: [
- Image.file(
- File(image.filePath),
- fit: BoxFit.cover,
- width: double.infinity,
- errorBuilder:
- (_, __, ___) => const Center(
- child: Icon(
- Icons.broken_image,
- color: Variables.textDisabled,
- ),
+ return ImageContextMenu(
+ image: image,
+ onImageDeleted: () => refreshData(),
+ child: GestureDetector(
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder:
+ (_) => ImageDetailsPage(
+ imagePath: image.filePath,
+ imageId: image.id,
+ projectId: widget.projectId,
),
),
- if (image.tags.isNotEmpty)
- Positioned(
- bottom: 0,
- left: 0,
- right: 0,
- child: Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.bottomCenter,
- end: Alignment.topCenter,
- colors: [
- Colors.black.withOpacity(0.8),
- Colors.transparent,
- ],
+ );
+ },
+ child: Container(
+ height: (index % 3 == 0) ? 240 : 180,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: Variables.surfaceSubtle,
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.file(
+ File(image.filePath),
+ fit: BoxFit.cover,
+ width: double.infinity,
+ errorBuilder:
+ (_, __, ___) => const Center(
+ child: Icon(
+ Icons.broken_image,
+ color: Variables.textDisabled,
+ ),
),
- ),
- child: Wrap(
- spacing: 4,
- runSpacing: 4,
- children:
- image.tags.take(3).map((tag) {
- return Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- color: Colors.white.withOpacity(0.2),
- borderRadius: BorderRadius.circular(4),
- border: Border.all(
- color: Colors.white.withOpacity(0.1),
+ ),
+ if (image.tags.isNotEmpty)
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ Colors.black.withOpacity(0.8),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ child: Wrap(
+ spacing: 4,
+ runSpacing: 4,
+ children:
+ image.tags.take(3).map((tag) {
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
),
- ),
- child: Text(
- tag.toUpperCase(),
- style: const TextStyle(
- color: Colors.white,
- fontSize: 9,
- fontFamily: 'GeneralSans',
- fontWeight: FontWeight.w600,
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(
+ color: Colors.white.withOpacity(0.1),
+ ),
),
- ),
- );
- }).toList(),
+ child: Text(
+ tag.toUpperCase(),
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 9,
+ fontFamily: 'GeneralSans',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+ }).toList(),
+ ),
),
),
- ),
- ],
+ ],
+ ),
),
),
);
diff --git a/lib/ui/pages/project_tag_page.dart b/lib/ui/pages/project_tag_page.dart
@@ -10,6 +10,7 @@ import '../../data/repos/project_repo.dart';
import '../../data/repos/note_repo.dart';
import 'image_save_page.dart';
import 'image_details_page.dart';
+import '../widgets/image_context_menu.dart';
class ProjectTagPage extends StatefulWidget {
final int projectId;
@@ -201,50 +202,54 @@ class _ProjectTagPageState extends State<ProjectTagPage> {
}
Widget _buildStaggeredImageItem(ImageModel image, {required int index}) {
- return GestureDetector(
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder:
- (_) => ImageDetailsPage(
- imagePath: image.filePath,
- imageId: image.id,
- projectId: widget.projectId,
- ),
+ return ImageContextMenu(
+ image: image,
+ onImageDeleted: () => _loadData(),
+ child: GestureDetector(
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder:
+ (_) => ImageDetailsPage(
+ imagePath: image.filePath,
+ imageId: image.id,
+ projectId: widget.projectId,
+ ),
+ ),
+ ).then((_) => _loadData());
+ },
+ child: Container(
+ // Simulate staggered heights similar to Alternate Page
+ height: (index % 3 == 0) ? 240 : 180,
+ decoration: BoxDecoration(
+ color: Variables.surfaceSubtle,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Variables.borderSubtle),
),
- ).then((_) => _loadData());
- },
- child: Container(
- // Simulate staggered heights similar to Alternate Page
- height: (index % 3 == 0) ? 240 : 180,
- decoration: BoxDecoration(
- color: Variables.surfaceSubtle,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(color: Variables.borderSubtle),
- ),
- // Explicitly clip content to border radius
- child: ClipRRect(
- borderRadius: BorderRadius.circular(16),
- child: Stack(
- fit: StackFit.expand,
- children: [
- Image.file(
- File(image.filePath),
- fit: BoxFit.cover,
- width: double.infinity,
- errorBuilder:
- (_, __, ___) => Container(
- color: Variables.surfaceSubtle,
- child: const Center(
- child: Icon(
- Icons.broken_image,
- color: Variables.textDisabled,
+ // Explicitly clip content to border radius
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.file(
+ File(image.filePath),
+ fit: BoxFit.cover,
+ width: double.infinity,
+ errorBuilder:
+ (_, __, ___) => Container(
+ color: Variables.surfaceSubtle,
+ child: const Center(
+ child: Icon(
+ Icons.broken_image,
+ color: Variables.textDisabled,
+ ),
),
),
- ),
- ),
- ],
+ ),
+ ],
+ ),
),
),
),
diff --git a/lib/ui/widgets/image_context_menu.dart b/lib/ui/widgets/image_context_menu.dart
@@ -0,0 +1,395 @@
+import 'dart:io';
+import 'dart:math' as math;
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:share_plus/share_plus.dart';
+import '../../data/models/image_model.dart';
+import '../../services/image_service.dart';
+import '../pages/share_to_file_page.dart';
+import '../styles/variables.dart';
+
+class ImageContextMenu extends StatelessWidget {
+ final ImageModel image;
+ final Widget child;
+ final VoidCallback onImageDeleted;
+
+ const ImageContextMenu({
+ super.key,
+ required this.image,
+ required this.child,
+ required this.onImageDeleted,
+ });
+
+ void _showRadialMenu(BuildContext context, Offset globalPosition) {
+ Navigator.of(context).push(
+ PageRouteBuilder(
+ opaque: false,
+ barrierDismissible: true,
+ barrierColor: Colors.black12, // Subtle dim
+ transitionDuration: const Duration(milliseconds: 200),
+ pageBuilder: (context, animation, secondaryAnimation) {
+ return FadeTransition(
+ opacity: animation,
+ child: _RadialMenuOverlay(
+ center: globalPosition,
+ image: image,
+ onImageDeleted: onImageDeleted,
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ // Get exact touch position
+ onLongPressStart: (details) => _showRadialMenu(context, details.globalPosition),
+ child: child,
+ );
+ }
+}
+
+class _RadialMenuOverlay extends StatefulWidget {
+ final Offset center;
+ final ImageModel image;
+ final VoidCallback onImageDeleted;
+
+ const _RadialMenuOverlay({
+ required this.center,
+ required this.image,
+ required this.onImageDeleted,
+ });
+
+ @override
+ State<_RadialMenuOverlay> createState() => _RadialMenuOverlayState();
+}
+
+class _RadialMenuOverlayState extends State<_RadialMenuOverlay> with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ late Animation<double> _scaleAnimation;
+
+ // Configuration
+ final double radius = 120.0;
+ final double buttonSize = 56.0;
+ final double arcSpan = 150.0;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ duration: const Duration(milliseconds: 350),
+ vsync: this,
+ );
+
+ _scaleAnimation = CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeOutBack,
+ );
+
+ _controller.forward();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ // Closes menu with animation
+ void _closeAnimated() {
+ _controller.reverse().then((_) {
+ if (mounted) Navigator.of(context).pop();
+ });
+ }
+
+ // --- ACTIONS ---
+
+ Future<void> _shareImage() async {
+ Navigator.of(context).pop();
+ final file = File(widget.image.filePath);
+ if (await file.exists()) {
+ await Share.shareXFiles([XFile(widget.image.filePath)]);
+ }
+ }
+
+ void _sendToFiles() {
+ Navigator.of(context).pop();
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => ShareToFilePage(sharedImage: File(widget.image.filePath)),
+ ),
+ );
+ }
+
+ Future<void> _renameImage() async {
+ // Hide buttons to not obstruct dialog
+ _controller.reverse();
+
+ final TextEditingController nameController = TextEditingController(text: widget.image.name);
+ await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: Colors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
+ title: const Text("Rename Image", style: TextStyle(fontFamily: 'GeneralSans', fontWeight: FontWeight.w600)),
+ content: TextField(
+ controller: nameController,
+ autofocus: true,
+ decoration: InputDecoration(
+ hintText: "Enter new name",
+ filled: true,
+ fillColor: Variables.surfaceSubtle,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text("Cancel", style: TextStyle(color: Variables.textSecondary, fontFamily: 'GeneralSans')),
+ ),
+ TextButton(
+ onPressed: () async {
+ if (nameController.text.isNotEmpty) {
+ await ImageService().renameImage(widget.image.id, nameController.text.trim());
+ widget.onImageDeleted(); // Trigger Refresh
+ Navigator.pop(ctx);
+ }
+ },
+ child: const Text("Save", style: TextStyle(color: Variables.textPrimary, fontFamily: 'GeneralSans', fontWeight: FontWeight.w600)),
+ ),
+ ],
+ ),
+ );
+
+ if (mounted) Navigator.of(context).pop();
+ }
+
+ Future<void> _deleteImage() async {
+ final confirm = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: Colors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
+ title: const Text("Delete Image?", style: TextStyle(fontFamily: 'GeneralSans', fontWeight: FontWeight.w600)),
+ content: const Text(
+ "This action cannot be undone.",
+ style: TextStyle(fontFamily: 'GeneralSans', color: Variables.textSecondary),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, false),
+ child: const Text("Cancel", style: TextStyle(color: Variables.textSecondary, fontFamily: 'GeneralSans')),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ child: const Text("Delete", style: TextStyle(color: Colors.red, fontFamily: 'GeneralSans', fontWeight: FontWeight.w600)),
+ ),
+ ],
+ ),
+ );
+
+ if (confirm == true) {
+ await ImageService().deleteImage(widget.image.id);
+ widget.onImageDeleted();
+ if (mounted) Navigator.of(context).pop();
+ }
+ }
+
+ void _notImplemented() {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: const Text("Coming soon", style: TextStyle(fontFamily: 'GeneralSans')),
+ backgroundColor: Variables.textPrimary,
+ behavior: SnackBarBehavior.floating,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ margin: const EdgeInsets.all(20),
+ duration: const Duration(seconds: 1),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final screenSize = MediaQuery.of(context).size;
+
+ // 1. Determine Horizontal Side
+ final bool isRightSide = widget.center.dx > screenSize.width / 2;
+ double baseAngle = isRightSide ? 180 : 0;
+
+ // 2. Determine Vertical clipping adjustments
+ double topBoundary = 120;
+ double bottomBoundary = screenSize.height - 120;
+
+ double rotationOffset = 0;
+ if (widget.center.dy < topBoundary) {
+ // Too close to TOP -> Rotate DOWN
+ rotationOffset = isRightSide ? -45 : 45;
+ } else if (widget.center.dy > bottomBoundary) {
+ // Too close to BOTTOM -> Rotate UP
+ rotationOffset = isRightSide ? 45 : -45;
+ }
+
+ baseAngle += rotationOffset;
+
+ // 3. Define Buttons
+ final buttons = [
+ _MenuButtonData(
+ svgPath: 'assets/icons/share_icon.svg',
+ onTap: _shareImage
+ ),
+ _MenuButtonData(
+ svgPath: 'assets/icons/files_icon.svg',
+ onTap: _sendToFiles
+ ),
+ _MenuButtonData(icon: Icons.drive_file_rename_outline, onTap: _renameImage),
+ _MenuButtonData(
+ svgPath: 'assets/icons/trash_icon.svg',
+ onTap: _deleteImage,
+ isDestructive: true
+ ),
+ _MenuButtonData(
+ svgPath: 'assets/icons/ai-search.svg',
+ onTap: _notImplemented
+ ),
+ ];
+
+ return Stack(
+ children: [
+ // Backdrop
+ Positioned.fill(
+ child: GestureDetector(
+ onTap: _closeAnimated,
+ behavior: HitTestBehavior.opaque,
+ child: Container(color: Colors.transparent),
+ ),
+ ),
+
+ // Buttons
+ ...List.generate(buttons.length, (index) {
+ final step = arcSpan / (buttons.length - 1);
+ final startAngle = baseAngle - (arcSpan / 2);
+ final angleDeg = startAngle + (step * index);
+
+ return _buildAnimatedButton(
+ angleDeg: angleDeg,
+ data: buttons[index],
+ );
+ }),
+ ],
+ );
+ }
+
+ Widget _buildAnimatedButton({
+ required double angleDeg,
+ required _MenuButtonData data,
+ }) {
+ final rad = angleDeg * (math.pi / 180);
+
+ return AnimatedBuilder(
+ animation: _scaleAnimation,
+ builder: (context, child) {
+ final r = radius * _scaleAnimation.value;
+ final dx = widget.center.dx + (r * math.cos(rad)) - (buttonSize / 2);
+ final dy = widget.center.dy + (r * math.sin(rad)) - (buttonSize / 2);
+
+ return Positioned(
+ left: dx,
+ top: dy,
+ child: Transform.scale(
+ scale: _scaleAnimation.value,
+ child: _FloatingCircleButton(
+ size: buttonSize,
+ data: data,
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+// Data Helper
+class _MenuButtonData {
+ final IconData? icon;
+ final String? svgPath;
+ final VoidCallback onTap;
+ final bool isDestructive;
+
+ _MenuButtonData({
+ this.icon,
+ this.svgPath,
+ required this.onTap,
+ this.isDestructive = false,
+ });
+}
+
+class _FloatingCircleButton extends StatefulWidget {
+ final double size;
+ final _MenuButtonData data;
+
+ const _FloatingCircleButton({
+ required this.size,
+ required this.data,
+ });
+
+ @override
+ State<_FloatingCircleButton> createState() => _FloatingCircleButtonState();
+}
+
+class _FloatingCircleButtonState extends State<_FloatingCircleButton> {
+ bool _isPressed = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final Color normalIconColor = widget.data.isDestructive ? Colors.red : Variables.textPrimary;
+ final Color activeIconColor = Colors.white;
+
+ final Color normalBgColor = Colors.white;
+ final Color activeBgColor = const Color(0xFF7C4DFF);
+
+ final currentColor = _isPressed ? activeIconColor : normalIconColor;
+ final currentBg = _isPressed ? activeBgColor : normalBgColor;
+ final scale = _isPressed ? 1.2 : 1.0;
+
+ return GestureDetector(
+ onTapDown: (_) => setState(() => _isPressed = true),
+ onTapUp: (_) => setState(() => _isPressed = false),
+ onTapCancel: () => setState(() => _isPressed = false),
+ onTap: widget.data.onTap,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 150),
+ curve: Curves.easeOut,
+ width: widget.size,
+ height: widget.size,
+ transform: Matrix4.identity()..scale(scale),
+ transformAlignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: currentBg,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.15),
+ blurRadius: 10,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ padding: const EdgeInsets.all(14),
+ child: widget.data.svgPath != null
+ ? SvgPicture.asset(
+ widget.data.svgPath!,
+ width: 18,
+ height: 18,
+ colorFilter: ColorFilter.mode(currentColor, BlendMode.srcIn),
+ )
+ : Icon(widget.data.icon, color: currentColor, size: 24),
+ ),
+ );
+ }
+}
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
+#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
+ g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
+ url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
+ url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,12 +7,14 @@ import Foundation
import file_selector_macos
import path_provider_foundation
+import share_plus
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+ SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
diff --git a/pubspec.yaml b/pubspec.yaml
@@ -30,6 +30,7 @@ dependencies:
flutter_box_transform: ^0.4.7
dotted_border: ^2.0.0
flutter_colorpicker: ^1.1.0
+ share_plus: ^12.0.1
dev_dependencies:
flutter_test:
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
@@ -7,8 +7,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
+#include <share_plus/share_plus_windows_plugin_c_api.h>
+#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
+ SharePlusWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
+ UrlLauncherWindowsRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
@@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
+ share_plus
+ url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST