diff --git a/app/lib/desktop/pages/chat/desktop_chat_page.dart b/app/lib/desktop/pages/chat/desktop_chat_page.dart index c307848ee9..899d6f1bec 100644 --- a/app/lib/desktop/pages/chat/desktop_chat_page.dart +++ b/app/lib/desktop/pages/chat/desktop_chat_page.dart @@ -1,9 +1,15 @@ +import 'dart:ui'; +import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'dart:io'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:pasteboard/pasteboard.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shimmer/shimmer.dart'; @@ -63,6 +69,7 @@ class DesktopChatPageState extends State with AutomaticKeepAliv bool isScrollingDown = false; bool _showVoiceRecorder = false; + bool _isDragging = false; var prefs = SharedPreferencesUtil(); late List apps; @@ -92,6 +99,14 @@ class DesktopChatPageState extends State with AutomaticKeepAliv _sendMessageUtil(textController.text.trim()); return KeyEventResult.handled; } + + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.keyV && + (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed)) { + _handlePaste(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }); @@ -207,71 +222,132 @@ class DesktopChatPageState extends State with AutomaticKeepAliv _requestFocusIfPossible(); } }, - child: CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.keyR, meta: true): _handleReload, + child: DropTarget( + onDragEntered: (_) => setState(() => _isDragging = true), + onDragExited: (_) => setState(() => _isDragging = false), + onDragDone: (detail) { + setState(() => _isDragging = false); + List files = detail.files.map((e) => File(e.path)).toList(); + context.read().addFiles(files); }, - child: Focus( - focusNode: _focusNode, - autofocus: true, - child: GestureDetector( - onTap: () { - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } - }, - child: Consumer3( - builder: (context, provider, connectivityProvider, appProvider, child) { - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - ResponsiveHelper.backgroundPrimary, - ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.8), - ], - ), - borderRadius: BorderRadius.circular(20), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Stack( - children: [ - _buildAnimatedBackground(), - - // Main content with glassmorphism - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.02), - borderRadius: BorderRadius.circular(20), + child: Stack( + children: [ + CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyR, meta: true): _handleReload, + }, + child: Focus( + focusNode: _focusNode, + autofocus: true, + child: GestureDetector( + onTap: () { + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + }, + child: Consumer3( + builder: (context, provider, connectivityProvider, appProvider, child) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + ResponsiveHelper.backgroundPrimary, + ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.8), + ], ), - child: Column( + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( children: [ - _buildModernHeader(appProvider), - if (provider.isLoadingMessages) _buildLoadingBar(), - Expanded( - child: _animationsInitialized - ? FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: _buildChatContent(provider, connectivityProvider), - ), - ) - : _buildChatContent(provider, connectivityProvider), + _buildAnimatedBackground(), + + // Main content with glassmorphism + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.02), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildModernHeader(appProvider), + if (provider.isLoadingMessages) _buildLoadingBar(), + Expanded( + child: _animationsInitialized + ? FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: _buildChatContent(provider, connectivityProvider), + ), + ) + : _buildChatContent(provider, connectivityProvider), + ), + _buildFloatingInputArea(provider, connectivityProvider), + ], + ), ), - _buildFloatingInputArea(provider, connectivityProvider), ], ), ), - ], - ), + ); + }, ), - ); - }, + ), + ), ), - ), + if (_isDragging) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + color: Colors.black.withOpacity(0.6), + child: Center( + child: DottedBorder( + borderType: BorderType.RRect, + radius: const Radius.circular(20), + dashPattern: const [10, 5], + color: Colors.white.withOpacity(0.4), + strokeWidth: 2, + child: Container( + width: MediaQuery.of(context).size.width * 0.7, + height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.file_upload_outlined, + size: 64, + color: Colors.white.withOpacity(0.8), + ), + const SizedBox(height: 16), + Text( + 'Drop files here to add to your message', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], ), )); } @@ -1529,6 +1605,64 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ); } + Future _handlePaste() async { + try { + final files = await Pasteboard.files(); + if (files.isNotEmpty) { + if (mounted) { + context.read().addFiles(files.map((e) => File(e)).toList()); + } + return; + } + + final imageBytes = await Pasteboard.image; + if (imageBytes != null) { + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/pasted_image_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(imageBytes); + if (mounted) { + context.read().addFiles([file]); + } + } else { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text; + if (text != null && text.isNotEmpty) { + final selection = textController.selection; + String newText; + int newSelectionIndex; + + if (selection.isValid) { + newText = textController.text.replaceRange( + selection.start, + selection.end, + text, + ); + newSelectionIndex = selection.start + text.length; + } else { + newText = textController.text + text; + newSelectionIndex = newText.length; + } + + textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newSelectionIndex), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to paste content.'), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + } + } + } + void _sendMessageUtil(String text) { var provider = context.read(); provider.setSendingMessage(true); diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 3c176d428d..ebb6869179 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; import 'package:omi/backend/http/api/apps.dart'; @@ -113,6 +114,42 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } + Future addFiles(List files) async { + if (selectedFiles.length + files.length > 4) { + AppSnackbar.showSnackbarError('You can only select up to 4 files'); + return; + } + + List filesToAdd = []; + List typesToAdd = []; + + for (var file in files) { + String ext = p.extension(file.path).toLowerCase().replaceAll('.', ''); + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic', 'tiff', 'tif'].contains(ext)) { + typesToAdd.add('image'); + } else { + typesToAdd.add('file'); + } + filesToAdd.add(file); + } + + if (filesToAdd.isNotEmpty) { + selectedFiles.addAll(filesToAdd); + selectedFileTypes.addAll(typesToAdd); + try { + await uploadFiles(filesToAdd, appProvider?.selectedChatAppId); + } catch (e) { + Logger.debug('Failed to upload files: $e'); + if (selectedFiles.length >= filesToAdd.length) { + selectedFiles.removeRange(selectedFiles.length - filesToAdd.length, selectedFiles.length); + selectedFileTypes.removeRange(selectedFileTypes.length - filesToAdd.length, selectedFileTypes.length); + } + AppSnackbar.showSnackbarError('File upload failed. Please try again.'); + } + notifyListeners(); + } + } + bool isFileUploading(String id) { return uploadingFiles[id] ?? false; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ac6a74a767..de3cc14913 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -139,6 +139,8 @@ dependencies: url: https://github.com/BasedHardware/whisper_flutter_new.git ref: 684fb43710dd4de5538ac2da7d0b1fa405363212 disk_space_2: ^1.0.11 + desktop_drop: ^0.7.0 + pasteboard: ^0.4.0 dependency_overrides: js: ^0.7.1