diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index 01e7fb2ec..ff5715fb7 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -1,13 +1,17 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; import 'package:bubble/bubble.dart'; import 'package:collection/collection.dart'; +import 'package:emoji_picker_flutter/locales/default_emoji_set_locale.dart'; import 'package:file_picker/file_picker.dart'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:flutter_chat_ui/flutter_chat_ui.dart' show @@ -19,6 +23,7 @@ import 'package:flutter_chat_ui/flutter_chat_ui.dart' TextMessage; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:logging/logging.dart'; @@ -30,6 +35,7 @@ import 'package:qaul_rpc/qaul_rpc.dart'; import 'package:record/record.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:utils/utils.dart'; +import 'package:geolocator/geolocator.dart'; import '../../../../../../decorators/cron_task_decorator.dart'; import '../../../../../providers/providers.dart'; @@ -37,6 +43,7 @@ import '../../../../../utils.dart'; import '../../../../../widgets/widgets.dart'; import '../../tab.dart'; import 'conditional/conditional.dart'; +import 'map_screen.dart'; part 'audio_message_widget.dart'; @@ -52,6 +59,8 @@ part 'group_settings.dart'; part 'image_message_widget.dart'; +part 'send_emoji.dart'; + typedef OnSendPressed = void Function(String rawText); const _kChatRouteName = '/chat'; @@ -182,6 +191,12 @@ class _ChatScreenState extends ConsumerState { worker.sendMessage(room.conversationId, msg.text); }, [room]); + final sendEmoji = useCallback((String emoji) { + if (!mounted) return; + final worker = ref.read(qaulWorkerProvider); + worker.sendMessage(room.conversationId, emoji); + }, [room]); + final l10n = AppLocalizations.of(context)!; return Scaffold( resizeToAvoidBottomInset: true, @@ -350,13 +365,11 @@ class _ChatScreenState extends ConsumerState { ); } }, - // the record package is not supported on Linux onSendAudioPressed: Platform.isLinux ? null : (room.messages?.isEmpty ?? true) ? null - : ({types.PartialText? text}) async { - // ignore: use_build_context_synchronously + : ({types.PartialText? text}) { if (!context.mounted) return; showModalBottomSheet( context: context, @@ -378,45 +391,50 @@ class _ChatScreenState extends ConsumerState { }, ); }, + onSendLocationPressed: + !(Platform.isAndroid || Platform.isIOS || Platform.isLinux) + ? null + : ({types.PartialText? text}) async {}, ), onMessageTap: (context, message) async { - if (message is! types.FileMessage || _isReceivingFile(message)) { + if (message is! types.FileMessage && + message is! types.TextMessage) { return; } - if (Platform.isIOS || Platform.isAndroid) { - OpenFilex.open(message.uri); + + if (message is types.TextMessage && + message.text.startsWith('geo:')) { + final geoUri = Uri.parse(message.text); + if (await canLaunchUrl(geoUri)) { + await launchUrl(geoUri, mode: LaunchMode.externalApplication); + } return; } - final file = Uri.file(message.uri); + if (message is types.FileMessage && !_isReceivingFile(message)) { + if (Platform.isIOS || Platform.isAndroid) { + OpenFilex.open(message.uri); + return; + } - final parentDirectory = File.fromUri(file).parent.uri; + final file = Uri.file(message.uri); + final parentDirectory = File.fromUri(file).parent.uri; - for (final uri in [file, parentDirectory]) { - if (await canLaunchUrl(uri)) { - launchUrl(uri); - return; + for (final uri in [file, parentDirectory]) { + if (await canLaunchUrl(uri)) { + launchUrl(uri); + return; + } } } }, textMessageBuilder: (message, {required int messageWidth, required bool showName}) { - final msgIdx = room.messages!.indexWhere( - (element) => element.messageIdBase58 == message.id); - - var prevMsgWasFromSamePerson = false; - if (msgIdx > 0) { - final prevMsg = room.messages![msgIdx - 1]; - prevMsgWasFromSamePerson = - prevMsg.content is TextMessageContent && - prevMsg.senderIdBase58 == message.author.id; - } - return TextMessage( message: message, usePreviewData: true, hideBackgroundOnEmojiMessages: true, - showName: showName && !prevMsgWasFromSamePerson, + showName: showName, emojiEnlargementBehavior: EmojiEnlargementBehavior.multi, nameBuilder: (usr) { var user = room.members diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart index 26fc4aee3..a4100c052 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart @@ -21,6 +21,8 @@ class _CustomInput extends StatefulWidget { this.onAttachmentPressed, this.onPickImagePressed, this.onSendAudioPressed, + this.onSendEmojiPicker, + this.onSendLocationPressed, this.initialText, this.disabledMessage, this.isDisabled = false, @@ -35,6 +37,10 @@ class _CustomInput extends StatefulWidget { final Function({types.PartialText? text})? onSendAudioPressed; + final Function({types.PartialText? text})? onSendLocationPressed; + + final Function({types.PartialText? text})? onSendEmojiPicker; + final SendButtonVisibilityMode sendButtonVisibilityMode; final String? initialText; @@ -78,10 +84,11 @@ class _CustomInputState extends State<_CustomInput> { super.dispose(); } - void _handleSendPressed() { + void _handleSendPressed({types.PartialText? locationMessage}) { final trimmedText = _textController.text.trim(); - if (trimmedText != '' || !widget.isTextRequired) { - final partialText = types.PartialText(text: trimmedText); + if (trimmedText != '' || locationMessage != null) { + final partialText = + locationMessage ?? types.PartialText(text: trimmedText); widget.onSendPressed(partialText); _textController.clear(); } @@ -173,24 +180,128 @@ class _CustomInputState extends State<_CustomInput> { suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ + if (widget.onSendLocationPressed != null) + _AttachmentButton( + icon: Icons.location_on, + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MapScreen( + key: const ValueKey('map'), + onLocationSelected: + (position) { + final roundedLat = position + .latitude + .toStringAsFixed(2); + final roundedLng = position + .longitude + .toStringAsFixed(2); + final locationMessage = + types.PartialText( + text: + 'geo:${roundedLat},${roundedLng}', + ); + _handleSendPressed( + locationMessage: + locationMessage); + }, + ), + ), + ); + }, + ), if (widget.onAttachmentPressed != null) _AttachmentButton( onPressed: () => _sendFilePressed( widget.onAttachmentPressed), - tooltip: AppLocalizations.of(context)!.sendFileTooltip, + tooltip: AppLocalizations.of(context)! + .sendFileTooltip, ), + _AttachmentButton( + icon: Icons.emoji_emotions, + onPressed: () async { + final scrollController = + ScrollController(); + showModalBottomSheet( + context: context, + enableDrag: true, + isDismissible: true, + builder: (_) { + return SingleChildScrollView( + controller: scrollController, + child: EmojiPicker( + onEmojiSelected: + (category, emoji) { + final currentText = + _textController.text; + final newText = + '$currentText${emoji.emoji}'; + _textController.value = + TextEditingValue( + text: newText, + selection: TextSelection + .fromPosition( + TextPosition( + offset: + newText.length), + ), + ); + }, + config: Config( + height: 300, + checkPlatformCompatibility: + true, + emojiSet: + getDefaultEmojiLocale, + locale: const Locale('en'), + emojiTextStyle: + const TextStyle( + fontSize: 18), + customBackspaceIcon: + const Icon( + Icons.backspace, + color: Colors.blue), + customSearchIcon: + const Icon(Icons.search, + color: Colors.blue), + viewOrderConfig: + const ViewOrderConfig(), + emojiViewConfig: + const EmojiViewConfig(), + skinToneConfig: + const SkinToneConfig(), + categoryViewConfig: + const CategoryViewConfig(), + bottomActionBarConfig: + const BottomActionBarConfig(), + searchViewConfig: + const SearchViewConfig(), + ), + ), + ); + }, + ).whenComplete(() { + scrollController.dispose(); + }); + }, + tooltip: AppLocalizations.of(context)! + .sendFileTooltip, + ), if (widget.onPickImagePressed != null) _AttachmentButton( icon: Icons.add_a_photo, onPressed: () => _sendFilePressed( widget.onPickImagePressed), - tooltip: AppLocalizations.of(context)!.sendFileTooltip, + tooltip: AppLocalizations.of(context)! + .sendFileTooltip, ), if (widget.onSendAudioPressed != null) _AttachmentButton( icon: Icons.mic_none, onPressed: widget.onSendAudioPressed, - tooltip: AppLocalizations.of(context)!.sendAudioTooltip, + tooltip: AppLocalizations.of(context)! + .sendAudioTooltip, ), ], ), diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/map_screen.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/map_screen.dart new file mode 100644 index 000000000..1ce30940c --- /dev/null +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/map_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:geolocator/geolocator.dart'; + +class MapScreen extends StatefulWidget { + const MapScreen({ + Key? key, + required this.onLocationSelected, + }) : super(key: key); + + final Function(LatLng) onLocationSelected; + + @override + _MapScreenState createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + late GoogleMapController mapController; + LatLng? _currentPosition; + LatLng? _selectedPosition; + + @override + void initState() { + super.initState(); + _checkAndRequestLocationPermission(); + } + + Future _checkAndRequestLocationPermission() async { + bool serviceEnabled; + LocationPermission permission; + + // Check if location services are enabled + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Location services are disabled.')), + ); + return; + } + + // Check and request location permissions + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Location permissions are denied.')), + ); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Location permissions are permanently denied. Please enable them in settings.'), + ), + ); + return; + } + + // If permissions are granted, update the current location + _updateCurrentLocation(); + } + + void _onMapCreated(GoogleMapController controller) { + mapController = controller; + } + + Future _updateCurrentLocation() async { + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + setState(() { + _currentPosition = LatLng(position.latitude, position.longitude); + }); + mapController.animateCamera( + CameraUpdate.newLatLng(_currentPosition!), + ); + } catch (e) { + debugPrint('Error getting location: $e'); + } + } + + void _onMarkerDragged(LatLng position) { + setState(() { + _currentPosition = position; + }); + widget.onLocationSelected(position); + } + + void _onMapTap(LatLng position) { + setState(() { + _selectedPosition = position; + }); + } + + void _onConfirmLocation() { + if (_selectedPosition != null) { + final roundedLat = _selectedPosition!.latitude.toStringAsFixed(2); + final roundedLng = _selectedPosition!.longitude.toStringAsFixed(2); + widget.onLocationSelected(LatLng( + double.parse(roundedLat), + double.parse(roundedLng), + )); + Navigator.pop(context); + } else if (_currentPosition != null) { + final roundedLat = _currentPosition!.latitude.toStringAsFixed(2); + final roundedLng = _currentPosition!.longitude.toStringAsFixed(2); + widget.onLocationSelected(LatLng( + double.parse(roundedLat), + double.parse(roundedLng), + )); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a location.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Select Location'), + actions: [ + IconButton( + icon: const Icon(Icons.check), + onPressed: _onConfirmLocation, + ), + IconButton( + icon: const Icon(Icons.my_location), + onPressed: _updateCurrentLocation, + ), + ], + ), + body: _currentPosition == null + ? const Center(child: CircularProgressIndicator()) + : GoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: CameraPosition( + target: _currentPosition!, + zoom: 12.0, + ), + onTap: _onMapTap, + markers: _selectedPosition != null + ? { + Marker( + markerId: const MarkerId('selected-location'), + position: _selectedPosition!, + draggable: true, + onDragEnd: _onMarkerDragged, + ), + } + : { + Marker( + markerId: const MarkerId('selectedLocation'), + position: _currentPosition!, + draggable: true, + onDragEnd: _onMarkerDragged, + ), + }, + ), + ); + } +} diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/send_emoji.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/send_emoji.dart new file mode 100644 index 000000000..d889f995b --- /dev/null +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/send_emoji.dart @@ -0,0 +1,38 @@ +part of 'chat.dart'; + +class SendEmojiDialog extends StatelessWidget { + const SendEmojiDialog({ + super.key, + required this.onEmojiSelected, + }); + + final void Function(String emoji) onEmojiSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 300, + child: EmojiPicker( + onEmojiSelected: (category, emoji) { + onEmojiSelected(emoji.emoji); + Navigator.pop(context); + }, + config: Config( + height: 300, + checkPlatformCompatibility: true, + emojiSet: getDefaultEmojiLocale, + locale: const Locale('en'), + emojiTextStyle: const TextStyle(fontSize: 18), + customBackspaceIcon: const Icon(Icons.backspace, color: Colors.blue), + customSearchIcon: const Icon(Icons.search, color: Colors.blue), + viewOrderConfig: const ViewOrderConfig(), + emojiViewConfig: const EmojiViewConfig(), + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(), + searchViewConfig: const SearchViewConfig(), + ), + ), + ); + } +} diff --git a/qaul_ui/packages/qaul_rpc/pubspec.yaml b/qaul_ui/packages/qaul_rpc/pubspec.yaml index 63f200469..4fee2394c 100644 --- a/qaul_ui/packages/qaul_rpc/pubspec.yaml +++ b/qaul_ui/packages/qaul_rpc/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: meta: ^1.9.1 protobuf: ^2.1.0 - uuid: ^3.0.7 + uuid: ^4.5.1 rxdart: ^0.27.7 equatable: ^2.0.5 fast_base58: ^0.2.1 diff --git a/qaul_ui/pubspec.yaml b/qaul_ui/pubspec.yaml index a38c827e8..942be644b 100644 --- a/qaul_ui/pubspec.yaml +++ b/qaul_ui/pubspec.yaml @@ -31,9 +31,9 @@ dependencies: fluent_ui: ^4.9.1 flutter_chat_types: ^3.6.2 flutter_chat_ui: ^1.6.10 - flutter_email_sender: ^6.0.2 - flutter_hooks: ^0.20.5 - flutter_svg: ^2.0.9 + flutter_email_sender: ^7.0.0 + flutter_hooks: ^0.21.2 + flutter_svg: ^2.0.17 font_awesome_flutter: ^10.6.0 hive: ^2.2.3 hive_flutter: ^1.1.0 @@ -60,10 +60,16 @@ dependencies: utils: { "path": "packages/utils" } version: ^3.0.2 flutter_markdown: ^0.7.1 - record: ^5.0.4 + record: ^6.0.0 audioplayers: ^6.1.1 app_links: ^6.3.3 open_filex: ^4.7.0 + emoji_picker_flutter: ^4.3.0 + + flutter_map: ^8.1.1 + latlong2: ^0.9.1 + google_maps_flutter: ^2.11.0 + geolocator: ^13.0.3 dev_dependencies: flutter_test: @@ -71,12 +77,12 @@ dev_dependencies: integration_test: sdk: flutter - build_runner: ^2.4.6 + build_runner: ^2.4.15 fast_base58: ^0.2.1 flutter_launcher_icons: ^0.14.3 flutter_lints: ^5.0.0 hive_generator: ^2.0.1 - uuid: ^3.0.7 + uuid: ^4.5.1 flutter_icons: ios: true diff --git a/qaul_ui/widgetbook/pubspec.yaml b/qaul_ui/widgetbook/pubspec.yaml index 92e1e1be1..765cde14b 100644 --- a/qaul_ui/widgetbook/pubspec.yaml +++ b/qaul_ui/widgetbook/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: path: ../ widgetbook: ^3.7.1 widgetbook_annotation: ^3.1.0 + flutter_map: ^4.0.0 + latlong2: ^0.8.2 dev_dependencies: flutter_test: