Files
eatvio_app/lib/features/chat/presentation/widgets/chat_bubble_widget.dart
2026-05-28 12:11:29 +03:00

333 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dismissible_page/dismissible_page.dart';
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: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';
import 'package:url_launcher/url_launcher.dart';
class ChatBubbleWidget extends StatelessWidget {
const ChatBubbleWidget({
super.key,
required this.sendTime,
required this.text,
required this.isCurrentUser,
required this.mediaData,
required this.isRead,
required this.messageState,
required this.messageUuid,
required this.userSlug,
this.messageType,
this.isConsultant,
this.urlsMediaInfo,
});
final DateTime sendTime;
final String text;
final bool isCurrentUser;
final bool isRead;
final List<dynamic> mediaData;
final MessageState messageState;
final String messageUuid;
final String userSlug;
final String? messageType;
final bool? isConsultant;
final List<Map<String, dynamic>>? urlsMediaInfo;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
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)
switch (messageState) {
MessageState.loading => Padding(
padding: const EdgeInsets.only(right: 4, bottom: 4),
child: SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 2, color: appTheme.blueA400)),
),
MessageState.sent => 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)),
),
MessageState.error => GestureDetector(
onTap: () {
showMessageActionsBottomSheet(
context,
resend: () {
context.read<ChatRoomCubit>().resendMessage(uuid: messageUuid, userSlug: userSlug);
},
delete: () {
context.read<ChatRoomCubit>().deleteMessage(messageUuid);
},
);
},
child: Padding(padding: const EdgeInsets.only(right: 4, bottom: 4), child: SvgPicture.asset('assets/icons/error_icon.svg')),
),
},
Container(
constraints: BoxConstraints(maxWidth: size.width * 0.7),
decoration: BoxDecoration(
color: isCurrentUser ? appTheme.gray50 : appTheme.gray60004,
borderRadius: mediaData.isNotEmpty
? BorderRadius.circular(24)
: 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: mediaData.isNotEmpty
? const EdgeInsets.all(8)
: EdgeInsets.only(top: 8, bottom: 8, left: isCurrentUser ? 16 : 8, right: isCurrentUser ? 8 : 16),
child: Column(
crossAxisAlignment: isCurrentUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (mediaData.isNotEmpty)
SizedBox(
width: size.width * 0.7,
child: CustomScrollView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
slivers: [
SliverList.separated(
itemBuilder: (BuildContext context, int index) {
final mediaItem = mediaData[index];
final isImage = mediaItem is File
? (mediaItem.path.toLowerCase().endsWith('.png') ||
mediaItem.path.toLowerCase().endsWith('.jpg') ||
mediaItem.path.toLowerCase().endsWith('.jpeg') ||
mediaItem.path.toLowerCase().endsWith('.gif') ||
mediaItem.path.toLowerCase().endsWith('.webp'))
: (mediaItem.toString().toLowerCase().contains('.png') ||
mediaItem.toString().toLowerCase().contains('.jpg') ||
mediaItem.toString().toLowerCase().contains('.jpeg') ||
mediaItem.toString().toLowerCase().contains('.gif') ||
mediaItem.toString().toLowerCase().contains('.webp'));
if (!isImage) {
// Получаем имя файла
String fileName;
if (mediaItem is File) {
fileName = mediaItem.path.split('/').last;
} else {
final urlString = mediaItem.toString();
// Получаем расширение файла из URL
String? fileExtension;
final urlParts = urlString.split('/');
if (urlParts.isNotEmpty) {
final lastPart = urlParts.last.split('?').first;
final dotIndex = lastPart.lastIndexOf('.');
if (dotIndex != -1 && dotIndex < lastPart.length - 1) {
fileExtension = lastPart.substring(dotIndex);
}
}
// Пытаемся найти имя файла в urls_media_info
String? fileNameFromInfo;
final mediaInfo = urlsMediaInfo;
if (mediaInfo != null && mediaInfo.isNotEmpty) {
for (final mediaInfoMap in mediaInfo) {
// Каждый элемент urls_media_info - это Map, где ключ - это URL
if (mediaInfoMap.isNotEmpty) {
for (final entry in mediaInfoMap.entries) {
final infoUrl = entry.key.toString();
// Сравниваем полные URL или только имя файла
if (infoUrl == urlString ||
urlString.contains(infoUrl) ||
infoUrl.contains(urlString) ||
urlString.split('/').last == infoUrl.split('/').last) {
final fileInfo = entry.value;
if (fileInfo is Map<String, dynamic> && fileInfo.containsKey('name')) {
final nameValue = fileInfo['name'];
if (nameValue != null) {
fileNameFromInfo = nameValue.toString();
break;
}
}
}
}
if (fileNameFromInfo != null && fileNameFromInfo.isNotEmpty) break;
}
}
}
if (fileNameFromInfo != null && fileNameFromInfo.isNotEmpty) {
// Добавляем расширение к названию файла, если его там нет
if (fileExtension != null && !fileNameFromInfo.endsWith(fileExtension)) {
fileName = '$fileNameFromInfo$fileExtension';
} else {
fileName = fileNameFromInfo;
}
} else {
// Fallback к старой логике, если информации нет
fileName = urlString.contains('/') ? urlString.split('/').last.split('?').first : urlString.replaceAll('chat_files/', '');
}
}
return Material(
color: Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.all(4),
horizontalTitleGap: 8,
onTap: () async {
if (mediaItem is File) {
// Для локальных файлов используем file:// URI
final fileUri = Uri.file(mediaItem.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
}
} else {
// Для URL файлов используем обычный launchUrl
final urlString = mediaItem.toString();
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
? Uri.parse(urlString)
: Uri.parse('https://$urlString');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
},
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
trailing: const Icon(Icons.download_outlined, color: Colors.black),
title: Text(
fileName,
maxLines: 2,
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
),
),
);
}
return GestureDetector(
onTap: () {
// Определяем, все ли изображения локальные
final allLocal = mediaData.every((item) => item is File);
context.pushTransparentRoute(PhotoViewScreen(imageUrls: mediaData, index: index, isLocal: allLocal));
},
child: Hero(
tag: '${mediaData[index]}',
transitionOnUserGestures: true,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: mediaItem is File ? Image.file(mediaItem) : CachedNetworkImage(imageUrl: '$mediaItem'),
),
),
);
},
separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 8),
itemCount: mediaData.length,
),
],
),
),
if (mediaData.isNotEmpty && _getDisplayText().isNotEmpty) const SizedBox(height: 8),
if (_getDisplayText().isNotEmpty) _buildLinkableText(_getDisplayText()),
],
),
),
),
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),
),
),
],
),
),
);
}
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));
}
}