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

@@ -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,
),
),
),
],
),
),
);
}
}