From 591a8a597d3ace8c5a4369cec1b5b42c04fe0e58 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Fri, 27 Jun 2025 15:06:46 +0300 Subject: [PATCH] feat(chat): add chat images/files preview --- ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/Info.plist | 2 +- .../image_compress_utility.dart | 16 +- lib/core/utils/date_time_utils.dart | 4 +- .../chat/presentation/pages/chat_screen.dart | 280 +++++++++++++----- .../widgets/chat_entry_widget.dart | 15 +- .../chat/repositories/chat_repository.dart | 4 +- .../blocked_users_repository.dart | 0 .../menu/services/blocked_users_service.dart | 24 ++ pubspec.yaml | 2 +- 10 files changed, 272 insertions(+), 81 deletions(-) create mode 100644 lib/features/menu/presentation/repositories/blocked_users_repository.dart create mode 100644 lib/features/menu/services/blocked_users_service.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 08e122ba..03dec547 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -388,7 +388,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -538,7 +538,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -576,7 +576,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 219c1ec3..0491da0c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 33 + 35 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/core/image_compressing/image_compress_utility.dart b/lib/core/image_compressing/image_compress_utility.dart index 6eb66f4e..e3c5fa65 100644 --- a/lib/core/image_compressing/image_compress_utility.dart +++ b/lib/core/image_compressing/image_compress_utility.dart @@ -7,11 +7,25 @@ abstract class ImageCompressUtility { static Future> compressFilesList(List files) async { final List compressedFilesList = []; for (int i = 0; i < files.length; i++) { - compressedFilesList.add(await compressFile(files[i])); + if (_isImageFile(files[i].path)) { + compressedFilesList.add(await compressFile(files[i])); + } else { + compressedFilesList.add(files[i]); + } } return compressedFilesList; } + static bool _isImageFile(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.gif') || + lower.endsWith('.bmp') || + lower.endsWith('.webp'); + } + static Future compressFile(File file) async { final image = img.decodeImage(file.readAsBytesSync())!; int width = image.width; diff --git a/lib/core/utils/date_time_utils.dart b/lib/core/utils/date_time_utils.dart index f10ae9ec..bcc82aeb 100644 --- a/lib/core/utils/date_time_utils.dart +++ b/lib/core/utils/date_time_utils.dart @@ -33,7 +33,7 @@ extension DateTimeExtension on DateTime { } } -String calculateDifference(DateTime dateTime) { +String calculateDifference(DateTime dateTime, [String? format]) { final localDateTime = dateTime.isUtc ? dateTime.toLocal() : dateTime; if (localDateTime.isToday()) { @@ -41,6 +41,6 @@ String calculateDifference(DateTime dateTime) { } else if (localDateTime.isYesterday()) { return 'Вчера'; } else { - return localDateTime.format('d MMMM', 'ru'); + return localDateTime.format(format ?? 'd MMMM', 'ru'); } } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index e2e222cd..f8b43e4f 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math'; +import 'package:dismissible_page/dismissible_page.dart'; import 'package:easy_debounce/easy_debounce.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; @@ -8,6 +9,7 @@ import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -47,6 +49,7 @@ class _ChatScreenState extends State { final List<({int id, int index})> unreadMessagesIdsCopy = []; final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); late AppLifecycleListener _listener; + List pickedFiles = []; void _onHide() { chatRoomCubit.cancelTimer(); @@ -336,73 +339,155 @@ class _ChatScreenState extends State { ), Container( padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(width: 8), - InkWell( - onTap: () => showImagePicker(state.chatRoom.users.receiver.slug), - customBorder: const CircleBorder(), - child: Ink( - height: 40, - width: 40, - child: Transform.rotate(angle: pi / 4, child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99))), + SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (int i = 0; i < pickedFiles.length; i++) ...[ + Stack( + clipBehavior: Clip.none, + children: [ + _isImageFile(pickedFiles[i].path) + ? GestureDetector( + onTap: () { + final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList(); + final imageIndex = _imageIndexAt(pickedFiles, i); + + context.pushTransparentRoute( + PhotoViewScreen( + imageUrls: imageFiles.map((e) => e.path).toList(), + isLocal: true, + index: imageIndex, + ), + ); + }, + child: Hero( + tag: pickedFiles[i].path, + transitionOnUserGestures: true, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 104, + width: 104, + child: Image.file( + pickedFiles[i], + fit: BoxFit.cover, + ), + ), + ), + ), + ) + : _buildFilePreview(pickedFiles[i]), + Positioned( + top: -8, + right: -8, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + setState(() { + pickedFiles.removeAt(i); + }); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: appTheme.gray50, + shape: BoxShape.circle, + ), + child: const Icon( + size: 20, + Icons.close, + color: Colors.black, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 8), + ], + ], ), ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 40, - child: TextFormField( - expands: true, - textCapitalization: TextCapitalization.sentences, - controller: textEditingController, - maxLines: null, - minLines: null, - keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - hintText: 'Сообщение', - hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)), - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: 8), + InkWell( + onTap: () => showImagePicker(state.chatRoom.users.receiver.slug), + customBorder: const CircleBorder(), + child: Ink( + height: 40, + width: 40, + child: Transform.rotate(angle: pi / 4, child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99))), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 40, + child: TextFormField( + expands: true, + textCapitalization: TextCapitalization.sentences, + controller: textEditingController, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + hintText: 'Сообщение', + hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + ), ), ), ), - ), + const SizedBox(width: 8), + InkWell( + customBorder: const CircleBorder(), + onTap: () { + if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) { + chatRoomCubit.sendMessage( + data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(), + files: pickedFiles.isEmpty ? null : pickedFiles, + userSlug: state.chatRoom.users.receiver.slug, + ); + setState(() { + textEditingController.clear(); + pickedFiles.clear(); + }); + } + }, + child: Ink( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + height: 40, + width: 40, + child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white), + ), + ), + const SizedBox(width: 8), + ], ), - const SizedBox(width: 8), - InkWell( - customBorder: const CircleBorder(), - onTap: () { - if (textEditingController.text.isNotEmpty) { - chatRoomCubit.sendMessage( - data: textEditingController.text.trim(), - userSlug: state.chatRoom.users.receiver.slug, - ); - setState(() { - textEditingController.clear(); - }); - } - }, - child: Ink( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), - height: 40, - width: 40, - child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white), - ), - ), - const SizedBox(width: 8), ], ), ), @@ -425,6 +510,56 @@ class _ChatScreenState extends State { ); } + Widget _buildFilePreview(File file) { + final fileName = file.path.split('/').last; + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + height: 104, + width: 104, + color: const Color(0xFFE0E0E0), // Светло-серый фон + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.attach_file, size: 32, color: Colors.black54), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + fileName, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ); + } + + bool _isImageFile(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.gif') || + lower.endsWith('.bmp') || + lower.endsWith('.webp'); + } + + int _imageIndexAt(List allFiles, int targetIndex) { + int count = 0; + for (int i = 0; i < allFiles.length; i++) { + if (_isImageFile(allFiles[i].path)) { + if (i == targetIndex) return count; + count++; + } + } + return 0; + } + showImagePicker(String slug) { showCupertinoModalPopup( context: context, @@ -435,9 +570,14 @@ class _ChatScreenState extends State { Navigator.of(context).pop(); final picker = ImagePicker(); XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image != null) { - await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug); - } + setState(() { + if (image != null) { + pickedFiles.add(File(image.path)); + } + }); + // if (image != null) { + // await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug); + // } }, child: const Row( children: [ @@ -453,8 +593,11 @@ class _ChatScreenState extends State { final picker = ImagePicker(); List images = await picker.pickMultiImage(); if (images != []) { - List files = images.map((image) => File(image.path)).toList(); - await chatRoomCubit.sendMessage(files: files, userSlug: slug); + setState(() { + pickedFiles.addAll(images.map((image) => File(image.path)).toList()); + }); + // List files = images.map((image) => File(image.path)).toList(); + // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, child: const Row( @@ -493,8 +636,11 @@ class _ChatScreenState extends State { ], ); if (result != null) { - List files = result.paths.map((path) => File(path!)).toList(); - await chatRoomCubit.sendMessage(files: files, userSlug: slug); + // List files = result.paths.map((path) => File(path!)).toList(); + setState(() { + pickedFiles.addAll(result.paths.map((path) => File(path!)).toList()); + }); + // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, child: const Row( diff --git a/lib/features/chat/presentation/widgets/chat_entry_widget.dart b/lib/features/chat/presentation/widgets/chat_entry_widget.dart index 1338c60b..44cc67f9 100644 --- a/lib/features/chat/presentation/widgets/chat_entry_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_entry_widget.dart @@ -20,15 +20,14 @@ class ChatEntryWidget extends StatefulWidget { class _ChatEntryWidgetState extends State { @override Widget build(BuildContext context) { - return InkWell( - borderRadius: BorderRadius.circular(16), + return GestureDetector( onTap: () => GoRouter.of(context).pushNamed( 'chatScreen', extra: { 'chatId': widget.chatRoom.id.toString(), }, ), - child: Ink( + child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), @@ -61,7 +60,13 @@ class _ChatEntryWidgetState extends State { SizedBox( width: MediaQuery.sizeOf(context).width * 0.5, child: Text( - widget.chatRoom.lastMessage!.data.toString().trim(), + widget.chatRoom.lastMessage!.mediaData != null + ? widget.chatRoom.lastMessage!.isCurrentUserAuthor == true + ? 'Вы: 📎 Вложение' + : '📎 Вложение' + : widget.chatRoom.lastMessage!.isCurrentUserAuthor == true + ? 'Вы: ${widget.chatRoom.lastMessage!.data.toString().trim()}' + : widget.chatRoom.lastMessage!.data.toString().trim(), maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodyMediumBluegray800, @@ -77,7 +82,7 @@ class _ChatEntryWidgetState extends State { Padding( padding: const EdgeInsets.only(left: 8), child: Text( - widget.chatRoom.lastMessage!.updatedAt.format('dd MMM', 'ru'), + calculateDifference(widget.chatRoom.lastMessage!.updatedAt, 'dd MMM'), style: theme.textTheme.bodyMedium, ), ), diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index e249c708..54924fcd 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; @@ -51,7 +52,8 @@ class ChatRepository { data: data, mediaData: multipartFiles.isEmpty ? null : multipartFiles, ); - } catch (e) { + } catch (e, st) { + log(e.toString(), stackTrace: st); rethrow; } } diff --git a/lib/features/menu/presentation/repositories/blocked_users_repository.dart b/lib/features/menu/presentation/repositories/blocked_users_repository.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/features/menu/services/blocked_users_service.dart b/lib/features/menu/services/blocked_users_service.dart new file mode 100644 index 00000000..590d979f --- /dev/null +++ b/lib/features/menu/services/blocked_users_service.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'blocked_users_service.g.dart'; + +@RestApi() +abstract class BlockedUserService { + factory BlockedUserService(Dio dio, {String baseUrl}) = _BlockedUserService; + + @GET('/api/blocked_users') + Future getBlockedUsers(); + + @POST('/api/blocked_users/block') + Future blockUser({ + String? slug, + String? id, + }); + + @POST('/api/blocked_users/unblock') + Future unblockUser({ + String? slug, + String? id, + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8ba374ec..e91dc215 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.1.10+40 +version: 1.1.12+42 publish_to: 'none' environment: