feat(chat): add plan order message widget and enhance chat bubble to support message types

This commit is contained in:
mso7nueh
2025-12-19 15:08:11 +03:00
parent 5c9a9c22ba
commit 22b2e5f249
42 changed files with 611 additions and 37 deletions

View File

@@ -17,3 +17,11 @@ abstract class OAuthVerificationConfirmResponse with _$OAuthVerificationConfirmR
}

View File

@@ -11,3 +11,11 @@ abstract class OAuthVerificationRequest with _$OAuthVerificationRequest {
}

View File

@@ -11,3 +11,11 @@ abstract class OAuthVerificationRequestResponse with _$OAuthVerificationRequestR
}

View File

@@ -15,6 +15,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type') String? type,
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
}) = _ChatMessageModel;

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub
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/features/chat/presentation/widgets/plan_order_message_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
@@ -221,14 +222,36 @@ class _ChatScreenState extends State<ChatScreen> {
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
index + 1 == state.chatMessages.length) {
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
final message = state.chatMessages[index];
final isPlanOrderMessage = message.type == 'plan_order';
PacketOptions? messageOption;
if (isPlanOrderMessage && chosenPlan != null) {
if (chosenPlan.optionType == 'options1') {
messageOption = chosenPlan.plan.packet1Options;
} else if (chosenPlan.optionType == 'options2') {
messageOption = chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType == 'options3') {
messageOption = chosenPlan.plan.packet3Options;
}
}
return Column(
children: [
_messageDate(state.chatMessages[index].updatedAt),
_messageDate(message.updatedAt),
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
PlanOrderMessageWidget(
planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug,
price: messageOption.price,
sendTime: message.updatedAt,
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: Key(state.chatMessages[index].id.toString()),
key: Key(message.id.toString()),
onVisibilityChanged: (VisibilityInfo info) {
if (!state.chatMessages[index].isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIdsCopy.add((id: state.chatMessages[index].id, index: index));
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIdsCopy.add((id: message.id, index: index));
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
if (!result) return;
@@ -242,44 +265,74 @@ class _ChatScreenState extends State<ChatScreen> {
}
},
child: ChatBubbleWidget(
isRead: state.chatMessages[index].isRead,
sendTime: state.chatMessages[index].updatedAt,
text: state.chatMessages[index].data.trim(),
isRead: message.isRead,
sendTime: message.updatedAt,
text: message.data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: state.chatMessages[index].mediaData ?? [],
messageState: state.chatMessages[index].messageState,
messageUuid: state.chatMessages[index].uuid ?? '',
mediaData: message.mediaData ?? [],
messageState: message.messageState,
messageUuid: message.uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
messageType: message.type,
isConsultant: amIConsultant,
),
),
],
);
}
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
return VisibilityDetector(
key: Key(state.chatMessages[index].id.toString()),
onVisibilityChanged: (VisibilityInfo info) {
if (!state.chatMessages[index].isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIds.add((id: state.chatMessages[index].id, index: index));
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
if (!result) return;
if (!context.mounted) return;
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
unreadMessagesIds.clear();
});
}
},
child: ChatBubbleWidget(
isRead: state.chatMessages[index].isRead,
sendTime: state.chatMessages[index].updatedAt,
text: state.chatMessages[index].data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: state.chatMessages[index].mediaData ?? [],
messageState: state.chatMessages[index].messageState,
messageUuid: state.chatMessages[index].uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
),
final message = state.chatMessages[index];
final isPlanOrderMessage = message.type == 'plan_order';
PacketOptions? messageOption;
if (isPlanOrderMessage && chosenPlan != null) {
if (chosenPlan.optionType == 'options1') {
messageOption = chosenPlan.plan.packet1Options;
} else if (chosenPlan.optionType == 'options2') {
messageOption = chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType == 'options3') {
messageOption = chosenPlan.plan.packet3Options;
}
}
return Column(
children: [
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
PlanOrderMessageWidget(
planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug,
price: messageOption.price,
sendTime: message.updatedAt,
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: Key(message.id.toString()),
onVisibilityChanged: (VisibilityInfo info) {
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIds.add((id: message.id, index: index));
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
if (!result) return;
if (!context.mounted) return;
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
unreadMessagesIds.clear();
});
}
},
child: ChatBubbleWidget(
isRead: message.isRead,
sendTime: message.updatedAt,
text: message.data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: message.mediaData ?? [],
messageState: message.messageState,
messageUuid: message.uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
messageType: message.type,
isConsultant: amIConsultant,
),
),
],
);
},
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),

View File

@@ -9,6 +9,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/message_action_bottom_sheet.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
@@ -24,7 +25,9 @@ class ChatBubbleWidget extends StatelessWidget {
required this.isRead,
required this.messageState,
required this.messageUuid,
required this.userSlug
required this.userSlug,
this.messageType,
this.isConsultant,
});
final DateTime sendTime;
@@ -35,6 +38,8 @@ class ChatBubbleWidget extends StatelessWidget {
final MessageState messageState;
final String messageUuid;
final String userSlug;
final String? messageType;
final bool? isConsultant;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
@@ -144,8 +149,8 @@ class ChatBubbleWidget extends StatelessWidget {
],
),
),
if (mediaData.isNotEmpty && text.isNotEmpty) const SizedBox(height: 8),
if (text.isNotEmpty) Text(text, style: TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16)),
if (mediaData.isNotEmpty && _getDisplayText().isNotEmpty) const SizedBox(height: 8),
if (_getDisplayText().isNotEmpty) _buildLinkableText(_getDisplayText()),
],
),
),
@@ -163,4 +168,88 @@ class ChatBubbleWidget extends StatelessWidget {
),
);
}
String _getDisplayText() {
if (messageType == 'plan_order') {
if (isConsultant == true) {
// Текст для консультанта
return '1. Уточните у подопечного его цели и пожелания\n'
'2. Согласуйте и выберите дату старта плана\n'
'3. Ожидайте уведомления об успешной оплате\n'
'4. После оплаты предоставьте необходимые данные, согласно вашему плану питания';
} else {
// Текст для пользователя
return 'Укажите вашу цель в работе с консультантом\n'
'Согласуйте удобную дату старта плана, после чего вам будет направлена ссылка на оплату.';
}
}
return text;
}
Widget _buildLinkableText(String text) {
final urlRegex = RegExp(
r'(?:(?:https?|ftp):\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)',
caseSensitive: false,
);
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
final linkStyle = textStyle.copyWith(
color: appTheme.blueA400,
decoration: TextDecoration.underline,
);
final matches = urlRegex.allMatches(text);
if (matches.isEmpty) {
return Text(text, style: textStyle);
}
final spans = <TextSpan>[];
int lastEnd = 0;
for (final match in matches) {
// Добавляем текст до ссылки
if (match.start > lastEnd) {
spans.add(TextSpan(
text: text.substring(lastEnd, match.start),
style: textStyle,
));
}
// Добавляем кликабельную ссылку
final url = match.group(0)!;
final fullUrl = url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')
? url
: 'https://$url';
spans.add(TextSpan(
text: url,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () async {
try {
final uri = Uri.parse(fullUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
// Игнорируем ошибки открытия ссылок
}
},
));
lastEnd = match.end;
}
// Добавляем оставшийся текст после последней ссылки
if (lastEnd < text.length) {
spans.add(TextSpan(
text: text.substring(lastEnd),
style: textStyle,
));
}
return RichText(
text: TextSpan(children: spans),
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class PlanOrderMessageWidget extends StatelessWidget {
const PlanOrderMessageWidget({
super.key,
required this.planName,
required this.planSlug,
required this.price,
required this.sendTime,
required this.isCurrentUser,
});
final String planName;
final String planSlug;
final dynamic price;
final DateTime sendTime;
final bool isCurrentUser;
String _formatPrice(dynamic price) {
if (price == null) return '0₽';
if (price is num) {
return '${price.toStringAsFixed(0)}₽';
}
if (price is String) {
final numPrice = num.tryParse(price);
if (numPrice != null) {
return '${numPrice.toStringAsFixed(0)}₽';
}
return price.endsWith('₽') ? price : '$price₽';
}
return '0₽';
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final formattedPrice = _formatPrice(price);
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
final linkStyle = textStyle.copyWith(
color: appTheme.blueA400,
decoration: TextDecoration.underline,
);
return SizedBox(
width: size.width,
child: Padding(
padding: EdgeInsets.fromLTRB(isCurrentUser ? 64 : 16, 10, isCurrentUser ? 16 : 64, 4),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (isCurrentUser)
Padding(
padding: const EdgeInsets.only(right: 4, bottom: 4),
child: Text(
sendTime.format('HH:mm', 'ru'),
style: const TextStyle(color: Color(0xFF7B8794), height: 1, fontSize: 10),
),
),
Container(
constraints: BoxConstraints(maxWidth: size.width * 0.7),
decoration: BoxDecoration(
color: isCurrentUser ? appTheme.gray50 : appTheme.gray60004,
borderRadius: BorderRadius.horizontal(
left: isCurrentUser ? const Radius.circular(32) : const Radius.circular(16),
right: isCurrentUser ? const Radius.circular(16) : const Radius.circular(24),
),
),
child: Padding(
padding: EdgeInsets.only(
top: 8,
bottom: 8,
left: isCurrentUser ? 16 : 8,
right: isCurrentUser ? 8 : 16,
),
child: RichText(
text: TextSpan(
style: textStyle,
children: [
const TextSpan(text: 'Я хочу заказать '),
TextSpan(
text: planName,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () {
GoRouter.of(context).pushNamed(
'aboutPlanScreen',
extra: {
'slug': planSlug,
'index': null,
'navigateToComments': false,
},
);
},
),
TextSpan(text: ' по цене $formattedPrice'),
],
),
),
),
),
if (!isCurrentUser)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text(
sendTime.format('HH:mm', 'ru'),
style: const TextStyle(
color: Color(0xFF7B8794),
height: 1,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
}

View File

@@ -139,5 +139,13 @@ void main() {

View File

@@ -73,6 +73,14 @@ void main() {

View File

@@ -87,6 +87,14 @@ void main() {

View File

@@ -36,6 +36,14 @@ void main() {

View File

@@ -62,6 +62,14 @@ void main() {

View File

@@ -31,6 +31,14 @@ void main() {

View File

@@ -28,6 +28,14 @@ void main() {

View File

@@ -32,6 +32,14 @@ void main() {

View File

@@ -112,6 +112,14 @@ void main() {

View File

@@ -35,6 +35,14 @@ void main() {

View File

@@ -30,6 +30,14 @@ void main() {

View File

@@ -66,6 +66,14 @@ void main() {

View File

@@ -118,6 +118,14 @@ void main() {

View File

@@ -55,6 +55,14 @@ void main() {

View File

@@ -137,6 +137,14 @@ void main() {

View File

@@ -98,6 +98,14 @@ void main() {

View File

@@ -166,6 +166,14 @@ void main() {

View File

@@ -59,6 +59,14 @@ void main() {

View File

@@ -44,6 +44,14 @@ void main() {

View File

@@ -98,6 +98,14 @@ void main() {

View File

@@ -78,6 +78,14 @@ void main() {

View File

@@ -68,5 +68,13 @@ void main() {

View File

@@ -73,6 +73,14 @@ void main() {

View File

@@ -60,6 +60,14 @@ void main() {

View File

@@ -92,6 +92,14 @@ void main() {

View File

@@ -44,6 +44,14 @@ void main() {

View File

@@ -110,6 +110,14 @@ void main() {

View File

@@ -140,6 +140,14 @@ void main() {

View File

@@ -52,6 +52,14 @@ void main() {

View File

@@ -52,6 +52,14 @@ void main() {

View File

@@ -1030,6 +1030,14 @@ void main() {

View File

@@ -65,3 +65,11 @@ void main() {