From 94d3e44354388db4a316892b6f826a7874dd10fb Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 11 Jun 2026 17:14:36 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B2=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20+=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D1=81?= =?UTF-8?q?=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=B8=20=D0=BE=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модели (lib/models/): - attachment.dart — новые типы ShareAttachment (OG-карточка ссылки) и CallAttachment (звонок); FILE получил вложенное превью и поле token; LOCATION — поле zoom; единый декодер previewData (сырой WebP → data-URI) вынесен в decodeAttachPreview - poll.dart — корректный парсинг голосов ({userId,timestamp}) и признак своего голоса (options & 1); helper withStateMap Лента сообщений (message_bubble.dart): - SHARE — карточка предпросмотра ссылки (картинка, домен, заголовок, описание), тап открывает URL - CALL — отображение звонков: аудио/видео/групповые/пропущенные, длительность, направление; симметричная иконка - LOCATION — карточка геопозиции, тап открывает карту - FILE — превью-картинка над строкой файла - кликабельные ссылки в тексте и подписях (LinkText) - фикс лишней ширины бабблов гео/share/опроса (IntrinsicWidth + stretch) - оптимизация рендера фото: убран fade-in, уменьшен размер декода плиток фото-сетки Опросы: - интерактивное голосование через opcode 304 (polls.dart, poll_view.dart): одиночный/множественный выбор, отметка своего ответа, проценты Список чатов (chats.dart): - превью типа вложения для сообщений без текста («Фото», «Опрос: …», «Звонок», «Геопозиция», «Файл: …», «Ссылка: …» и т.д.) Настройки разработчика: - тумблер «Предпросмотр ссылок» (app_link_preview.dart) — отключает SHARE-карточки, оставляя текст со ссылкой; применяется на лету Прочее: - зависимость url_launcher ^6.3.1 (open_external_url / open_location_on_map) - bump версии 0.5.0+11 → 0.5.0+12 --- lib/backend/modules/calls.dart | 16 +- lib/backend/modules/chats.dart | 69 ++- lib/backend/modules/polls.dart | 30 ++ lib/core/config/app_link_preview.dart | 20 + lib/core/utils/link_opener.dart | 35 ++ .../screens/profile/debug_menu_screen.dart | 63 +++ lib/frontend/widgets/link_text.dart | 68 +++ lib/frontend/widgets/message_bubble.dart | 440 ++++++++++++++++-- lib/frontend/widgets/poll_view.dart | 151 +++++- lib/main.dart | 30 +- lib/models/attachment.dart | 203 +++++--- lib/models/poll.dart | 38 +- pubspec.lock | 64 +++ pubspec.yaml | 3 +- 14 files changed, 1085 insertions(+), 145 deletions(-) create mode 100644 lib/core/config/app_link_preview.dart create mode 100644 lib/core/utils/link_opener.dart create mode 100644 lib/frontend/widgets/link_text.dart diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 4787aff..5b14ccc 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -63,13 +63,15 @@ class CallsModule { bool isVideo = false, }) async { final conversationId = _uuidV4(); + // Структура подтверждена дампом основного сокета (opcode 78). final internalParams = jsonEncode({ - 'deviceId': _api.deviceId ?? '', - 'sdkVersion': '2.8.9', - 'clientAppKey': _clientAppKey(), 'platform': 'ANDROID', + 'sdkVersion': '0.1.16.4', + 'clientAppKey': 'CGPGAGLGDIHBABABA', + 'deviceId': _api.deviceId ?? '', 'protocolVersion': 5, - 'domainId': '', + 'onlyAdminCanRecord': false, + 'waitForAdmin': false, 'capabilities': '3c03f', }); @@ -120,12 +122,6 @@ class CallsModule { '-${s.substring(16, 20)}-${s.substring(20)}'; } - static String _clientAppKey() { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - final r = Random(); - return List.generate(17, (_) => chars[r.nextInt(chars.length)]).join(); - } - /// Fetch call history from opcode 79 Future> fetchHistory( int accountId, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 0699017..8827641 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -181,6 +181,57 @@ class ChatsModule { /// а кеша истории нет — UI должен отрисовать курсивную плашку. static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__'; + static String? attachPreviewLabel(dynamic attaches) { + if (attaches is! List || attaches.isEmpty) return null; + final first = attaches.first; + if (first is! Map) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return 'Фото'; + case 'VIDEO': + return 'Видео'; + case 'AUDIO': + return 'Голосовое сообщение'; + case 'FILE': + final name = first['name']?.toString(); + return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл'; + case 'STICKER': + return 'Стикер'; + case 'SHARE': + final title = first['title']?.toString(); + return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка'; + case 'POLL': + final title = first['title']?.toString(); + return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос'; + case 'LOCATION': + return 'Геопозиция'; + case 'CONTACT': + return 'Контакт'; + case 'CALL': + final video = first['callType']?.toString().toUpperCase() == 'VIDEO'; + final dur = (first['duration'] as num?)?.toInt() ?? 0; + final hangup = first['hangupType']?.toString(); + final failed = dur == 0 || + hangup == 'CANCELED' || + hangup == 'REJECTED' || + hangup == 'MISSED'; + if (first['joinLink'] != null) { + return video ? 'Групповой видеозвонок' : 'Групповой звонок'; + } + if (failed) return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; + return video ? 'Видеозвонок' : 'Звонок'; + default: + return null; + } + } + + static String? messagePreviewText(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return text; + return attachPreviewLabel(msg['attaches']); + } + static final _messageEventsController = StreamController.broadcast(); static Stream get messageEvents => @@ -369,7 +420,7 @@ class ChatsModule { newRow['last_event_time'] = msgTime; } } - newRow['last_msg_text'] = msgText; + newRow['last_msg_text'] = messagePreviewText(msg); if (senderId != null) newRow['last_msg_sender'] = senderId; } if (unread != null) newRow['unread_count'] = unread; @@ -388,8 +439,20 @@ class ChatsModule { final newRow = Map.from(chatRow); if (latest.isNotEmpty) { final m = latest.first; + String? previewText = m['text']?.toString(); + if (previewText == null || previewText.isEmpty) { + final payloadRaw = m['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + final payload = jsonDecode(payloadRaw); + if (payload is Map) { + previewText = attachPreviewLabel(payload['attaches']); + } + } catch (_) {} + } + } newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); - newRow['last_msg_text'] = m['text']; + newRow['last_msg_text'] = previewText ?? m['text']; newRow['last_msg_time'] = m['time']; newRow['last_msg_sender'] = m['sender_id']; } else { @@ -781,7 +844,7 @@ class ChatsModule { if (lastMsg is Map) { lastMsgId = lastMsg['id'] as int?; lastMsgTime = lastMsg['time'] as int?; - lastMsgText = lastMsg['text'] as String?; + lastMsgText = messagePreviewText(lastMsg); lastMsgSenderId = lastMsg['sender'] as int?; } diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart index b3f9172..eecf427 100644 --- a/lib/backend/modules/polls.dart +++ b/lib/backend/modules/polls.dart @@ -58,4 +58,34 @@ class PollsModule extends ChangeNotifier { _inFlight.remove(pollId); } } + + Future vote( + int chatId, + String messageId, + int pollId, + List answersIds, + ) async { + try { + final response = await _api.sendRequest(Opcode.sendVote, { + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'pollId': pollId, + 'answersIds': answersIds, + }); + if (!response.isOk) return false; + + final data = response.payload; + final state = data is Map ? data['state'] : null; + final cached = _cache[pollId]; + if (state is Map && cached != null) { + _cache[pollId] = cached.withStateMap(state); + notifyListeners(); + } else { + await fetch(chatId, messageId, pollId, force: true); + } + return true; + } catch (_) { + return false; + } + } } diff --git a/lib/core/config/app_link_preview.dart b/lib/core/config/app_link_preview.dart new file mode 100644 index 0000000..924aad0 --- /dev/null +++ b/lib/core/config/app_link_preview.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppLinkPreview { + static const prefKey = 'dev_link_preview'; + static const bool defaultValue = true; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart new file mode 100644 index 0000000..b669cdc --- /dev/null +++ b/lib/core/utils/link_opener.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../frontend/widgets/custom_notification.dart'; + +Future openExternalUrl(BuildContext context, String url) async { + final uri = Uri.tryParse(url); + if (uri == null) { + showCustomNotification(context, 'Некорректная ссылка'); + return; + } + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) { + showCustomNotification(context, 'Не удалось открыть ссылку'); + } +} + +Future openLocationOnMap( + BuildContext context, + double latitude, + double longitude, { + double? zoom, +}) async { + final z = (zoom ?? 15).round(); + final geo = Uri.parse('geo:$latitude,$longitude?z=$z'); + if (await canLaunchUrl(geo)) { + final ok = await launchUrl(geo, mode: LaunchMode.externalApplication); + if (ok) return; + } + if (!context.mounted) return; + await openExternalUrl( + context, + 'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map', + ); +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index c366bb5..c24d8eb 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_link_preview.dart'; import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -744,6 +745,68 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppLinkPreview.current, + builder: (context, linkPreviewOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.link, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Предпросмотр ссылок', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Карточки с превью для ссылок в сообщениях', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: linkPreviewOn, + onChanged: (v) { + AppLinkPreview.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/link_text.dart b/lib/frontend/widgets/link_text.dart new file mode 100644 index 0000000..3c51c89 --- /dev/null +++ b/lib/frontend/widgets/link_text.dart @@ -0,0 +1,68 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../core/utils/link_opener.dart'; + +final RegExp _urlPattern = RegExp( + r'(https?://[^\s<>]+|www\.[^\s<>]+)', + caseSensitive: false, +); + +class LinkText extends StatefulWidget { + final String text; + final TextStyle style; + + const LinkText({super.key, required this.text, required this.style}); + + static bool hasLinks(String? text) => + text != null && _urlPattern.hasMatch(text); + + @override + State createState() => _LinkTextState(); +} + +class _LinkTextState extends State { + final List _recognizers = []; + + @override + void dispose() { + for (final r in _recognizers) { + r.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + + final spans = []; + var cursor = 0; + for (final match in _urlPattern.allMatches(widget.text)) { + if (match.start > cursor) { + spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); + } + final url = match.group(0)!; + final target = url.startsWith('www.') ? 'https://$url' : url; + final recognizer = TapGestureRecognizer() + ..onTap = () => openExternalUrl(context, target); + _recognizers.add(recognizer); + spans.add( + TextSpan( + text: url, + style: const TextStyle(decoration: TextDecoration.underline), + recognizer: recognizer, + ), + ); + cursor = match.end; + } + if (cursor < widget.text.length) { + spans.add(TextSpan(text: widget.text.substring(cursor))); + } + + return Text.rich(TextSpan(style: widget.style, children: spans)); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 859ab66..754c24e 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -14,7 +14,10 @@ import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; import '../../core/utils/media_cache.dart'; import '../../core/utils/download_progress.dart'; +import '../../core/utils/link_opener.dart'; +import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; +import 'link_text.dart'; import '../../models/attachment.dart'; import 'poll_view.dart'; import 'photo_viewer.dart'; @@ -136,12 +139,19 @@ class MessageBubble extends StatelessWidget { return BubbleShape.groupedMiddle; } - MessageType get _contentType => - _contentTypeCache[message] ??= _computeContentType(); + bool get _hasShareAttachment { + final a = message.attachments; + return a != null && a.isNotEmpty && a.first is ShareAttachment; + } - String get _clockText => - _clockTextCache[message] ??= - formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)); + MessageType get _contentType { + if (_hasShareAttachment) return _computeContentType(); + return _contentTypeCache[message] ??= _computeContentType(); + } + + String get _clockText => _clockTextCache[message] ??= formatClock( + DateTime.fromMillisecondsSinceEpoch(message.time), + ); MessageType _computeContentType() { if (message.isControl) return MessageType.control; @@ -163,6 +173,11 @@ class MessageBubble extends StatelessWidget { if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; + if (first is ShareAttachment) { + return AppLinkPreview.current.value + ? MessageType.attachment + : MessageType.text; + } return MessageType.attachment; } @@ -282,6 +297,16 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { + if (_hasShareAttachment) { + return ValueListenableBuilder( + valueListenable: AppLinkPreview.current, + builder: (context, _, _) => _buildBubble(context), + ); + } + return _buildBubble(context); + } + + Widget _buildBubble(BuildContext context) { final cs = Theme.of(context).colorScheme; final contentType = _contentType; @@ -582,17 +607,15 @@ class MessageBubble extends StatelessWidget { final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo); final hasReactions = reactionChips.isNotEmpty; + final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); final textWidget = isForwarded ? _buildForwardedInlineText(ctx, forwarded) - : Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), - ); + : (LinkText.hasLinks(message.text) + ? LinkText(text: message.text!, style: textStyle) + : Text(message.text ?? '', style: textStyle)); final metaWidget = Text( - message.status == 'EDITED' - ? '$_clockText ред.' - : _clockText, + message.status == 'EDITED' ? '$_clockText ред.' : _clockText, style: TextStyle(color: ctx.dim, fontSize: 10), ); @@ -781,6 +804,11 @@ class MessageBubble extends StatelessWidget { return _buildPollAttachment(ctx, polls.first); } + final shares = attachments.whereType().toList(); + if (shares.isNotEmpty) { + return _buildShareContent(ctx, shares.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(ctx, attachments.first); @@ -792,14 +820,150 @@ class MessageBubble extends StatelessWidget { Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: PollView( - chatId: message.chatId, - messageId: message.id, - pollId: poll.pollId, - fallbackTitle: poll.title ?? message.text, - textColor: ctx.text, - dimColor: ctx.dim, - accentColor: ctx.text, + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + PollView( + chatId: message.chatId, + messageId: message.id, + pollId: poll.pollId, + myId: myId, + fallbackTitle: poll.title ?? message.text, + textColor: ctx.text, + dimColor: ctx.dim, + accentColor: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + ), + _buildMeta(ctx), + ], + ), + ), + ); + } + + Widget _buildShareContent(_BubbleCtx ctx, ShareAttachment share) { + final hasText = message.text != null && message.text!.isNotEmpty; + final image = share.image; + final imageUrl = image?.baseUrl ?? image?.previewData ?? ''; + final cardColor = isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) + : ctx.cs.surfaceContainerHigh; + final host = + share.host ?? + (share.url != null ? Uri.tryParse(share.url!)?.host : null); + + final card = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: share.url == null + ? null + : () { + Haptics.tap(); + openExternalUrl(ctx.context, share.url!); + }, + child: Container( + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (imageUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: imageUrl, + width: 280, + height: 140, + fit: BoxFit.cover, + memCacheWidth: 560, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (host != null && host.isNotEmpty) ...[ + Text( + host, + style: TextStyle( + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ], + if (share.title != null && share.title!.isNotEmpty) + Text( + share.title!, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (share.description != null && + share.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + share.description!, + style: TextStyle( + color: ctx.dim, + fontSize: 13, + height: 1.25, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasText) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: LinkText( + text: message.text!, + style: TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + ), + ), + ), + const SizedBox(height: 6), + ], + card, + _buildMeta(ctx), + ], + ), + ), ), ); } @@ -1105,7 +1269,8 @@ class MessageBubble extends StatelessWidget { fit: BoxFit.cover, memCacheWidth: memWidth, memCacheHeight: memHeight, - fadeInDuration: const Duration(milliseconds: 120), + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height), ); } @@ -1213,8 +1378,9 @@ class MessageBubble extends StatelessWidget { } Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo, int index) { - final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) - .round(); + final cachePx = + (photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1247,8 +1413,9 @@ class MessageBubble extends StatelessWidget { String overlay, int index, ) { - final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) - .round(); + final cachePx = + (photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1308,10 +1475,11 @@ class MessageBubble extends StatelessWidget { } Widget _buildCaption(_BubbleCtx ctx) { - return Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), - ); + final style = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); + if (LinkText.hasLinks(message.text)) { + return LinkText(text: message.text!, style: style); + } + return Text(message.text ?? '', style: style); } Widget _buildGenericAttachment(_BubbleCtx ctx, MessageAttachment attachment) { @@ -1322,11 +1490,202 @@ class MessageBubble extends StatelessWidget { return _buildFileAttachment(ctx, attachment); case AttachmentType.sticker: return _buildStickerAttachment(ctx, attachment); + case AttachmentType.location: + return _buildLocationAttachment(ctx, attachment as LocationAttachment); + case AttachmentType.call: + return _buildCallAttachment(ctx, attachment as CallAttachment); default: return _buildTextContent(ctx); } } + Widget _buildCallAttachment(_BubbleCtx ctx, CallAttachment call) { + final missed = call.isMissedOrFailed; + final accent = isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary; + final iconColor = missed ? ctx.cs.error : accent; + + final IconData icon; + final String label; + if (call.isGroup) { + icon = call.isVideo ? Symbols.videocam : Symbols.groups; + label = call.isVideo ? 'Групповой видеозвонок' : 'Групповой звонок'; + } else if (call.isVideo) { + icon = Symbols.videocam; + label = missed + ? (isMe ? 'Отменённый видеозвонок' : 'Пропущенный видеозвонок') + : (isMe ? 'Исходящий видеозвонок' : 'Входящий видеозвонок'); + } else { + icon = Symbols.call; + label = missed + ? (isMe ? 'Отменённый звонок' : 'Пропущенный звонок') + : (isMe ? 'Исходящий звонок' : 'Входящий звонок'); + } + + final directionIcon = isMe ? Symbols.call_made : Symbols.call_received; + + final subtitle = missed + ? _clockText + : '$_clockText · ${formatSecondsMmSs((call.durationMs / 1000).round())}'; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 16, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: missed + ? ctx.cs.error.withValues(alpha: 0.12) + : (isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer), + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + directionIcon, + size: 13, + color: missed ? ctx.cs.error : ctx.dim, + ), + const SizedBox(width: 3), + Text( + subtitle, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLocationAttachment(_BubbleCtx ctx, LocationAttachment location) { + final lat = location.latitude; + final lon = location.longitude; + final coords = lat != null && lon != null + ? '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}' + : null; + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: lat == null || lon == null + ? null + : () { + Haptics.tap(); + openLocationOnMap( + ctx.context, + lat, + lon, + zoom: location.zoom, + ); + }, + child: Container( + width: 240, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) + : ctx.cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.location_on, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + location.title ?? 'Геопозиция', + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + location.address ?? coords ?? 'Открыть на карте', + style: TextStyle(color: ctx.dim, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + _buildMeta(ctx), + ], + ), + ), + ); + } + Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) { return Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -1422,6 +1781,9 @@ class MessageBubble extends StatelessWidget { final fileId = (file as dynamic).fileId as int?; final cacheName = '${fileId}_$name'; + final preview = file is FileAttachment ? file.preview : null; + final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; + return IntrinsicWidth( child: Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), @@ -1429,6 +1791,21 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + if (previewUrl.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: previewUrl, + width: 240, + height: 160, + fit: BoxFit.cover, + memCacheWidth: 480, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(height: 8), + ], Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -1895,10 +2272,7 @@ class MessageBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - Text( - _clockText, - style: TextStyle(color: ctx.dim, fontSize: 11), - ), + Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)), if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], ], ), diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart index 5a40436..c0c1904 100644 --- a/lib/frontend/widgets/poll_view.dart +++ b/lib/frontend/widgets/poll_view.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../main.dart'; +import '../../core/utils/haptics.dart'; import '../../models/poll.dart'; +import 'custom_notification.dart'; class PollView extends StatefulWidget { final int chatId; final String messageId; final int pollId; + final int myId; final String? fallbackTitle; final Color textColor; final Color dimColor; @@ -17,6 +21,7 @@ class PollView extends StatefulWidget { required this.chatId, required this.messageId, required this.pollId, + required this.myId, required this.textColor, required this.dimColor, required this.accentColor, @@ -28,10 +33,38 @@ class PollView extends StatefulWidget { } class _PollViewState extends State { + final Set _selected = {}; + bool _voting = false; + @override void initState() { super.initState(); - pollsModule.fetch(widget.chatId, widget.messageId, widget.pollId); + pollsModule.fetch( + widget.chatId, + widget.messageId, + widget.pollId, + force: true, + ); + } + + Future _vote(List answersIds) async { + if (_voting || answersIds.isEmpty) return; + Haptics.tap(); + setState(() => _voting = true); + final ok = await pollsModule.vote( + widget.chatId, + widget.messageId, + widget.pollId, + answersIds, + ); + if (!mounted) return; + setState(() { + _voting = false; + if (ok) _selected.clear(); + }); + if (!ok) { + showCustomNotification(context, 'Не удалось проголосовать'); + } } @override @@ -49,6 +82,7 @@ class _PollViewState extends State { final title = poll?.title.isNotEmpty == true ? poll!.title : (widget.fallbackTitle ?? 'Опрос'); + final showResults = poll != null && poll.votedBy(widget.myId); return ConstrainedBox( constraints: const BoxConstraints(minWidth: 220, maxWidth: 280), @@ -66,22 +100,117 @@ class _PollViewState extends State { ), const SizedBox(height: 2), Text( - poll == null - ? 'Загрузка опроса…' - : _votesLabel(poll.total), + poll == null ? 'Загрузка опроса…' : _subtitle(poll), style: TextStyle(color: widget.dimColor, fontSize: 12), ), const SizedBox(height: 10), - if (poll != null) - ...poll.answers.map((a) => _buildAnswer(a, poll.total)), + if (poll != null && showResults) + ...poll.answers.map((a) => _buildResultRow(a, poll.total)), + if (poll != null && !showResults) ...[ + ...poll.answers.map((a) => _buildChoiceRow(a, poll.isMultiple)), + if (poll.isMultiple) _buildVoteButton(), + ], ], ), ); } - Widget _buildAnswer(PollAnswer answer, int total) { + String _subtitle(Poll poll) { + final kind = poll.isMultiple + ? 'Несколько вариантов ответа' + : 'Один вариант ответа'; + if (poll.total == 0) return kind; + return '$kind · ${_votesLabel(poll.total)}'; + } + + Widget _buildChoiceRow(PollAnswer answer, bool multiple) { + final selected = _selected.contains(answer.answerId); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: _voting + ? null + : () { + if (multiple) { + setState(() { + selected + ? _selected.remove(answer.answerId) + : _selected.add(answer.answerId); + }); + } else { + _vote([answer.answerId]); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 2), + child: Row( + children: [ + Icon( + multiple + ? (selected + ? Symbols.check_box + : Symbols.check_box_outline_blank) + : Symbols.radio_button_unchecked, + size: 20, + color: selected ? widget.accentColor : widget.dimColor, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + answer.text, + style: TextStyle(color: widget.textColor, fontSize: 14), + ), + ), + if (_voting && !multiple) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: widget.dimColor, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildVoteButton() { + final enabled = _selected.isNotEmpty && !_voting; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: SizedBox( + width: double.infinity, + child: TextButton( + onPressed: enabled ? () => _vote(_selected.toList()..sort()) : null, + style: TextButton.styleFrom( + foregroundColor: widget.accentColor, + backgroundColor: widget.dimColor.withValues(alpha: 0.12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: _voting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: widget.accentColor, + ), + ) + : const Text('Проголосовать'), + ), + ), + ); + } + + Widget _buildResultRow(PollAnswer answer, int total) { final pct = total > 0 ? answer.voteCount / total : 0.0; - final pctLabel = '${(pct * 100).round()}%'; + final pctLabel = '${(answer.rate > 0 ? answer.rate : pct * 100).round()}%'; return Padding( padding: const EdgeInsets.only(bottom: 8), @@ -96,7 +225,10 @@ class _PollViewState extends State { style: TextStyle(color: widget.textColor, fontSize: 14), ), ), - const SizedBox(width: 8), + if (answer.mine) ...[ + Icon(Symbols.check_circle, size: 14, color: widget.accentColor), + const SizedBox(width: 4), + ], Text( pctLabel, style: TextStyle( @@ -123,7 +255,6 @@ class _PollViewState extends State { } String _votesLabel(int total) { - if (total == 0) return 'Нет голосов'; final mod10 = total % 10; final mod100 = total % 100; String word; diff --git a/lib/main.dart b/lib/main.dart index 78a4f9e..5dccad3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; +import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; @@ -98,6 +99,7 @@ void main() async { final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); + final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); @@ -129,6 +131,7 @@ void main() async { AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; + AppLinkPreview.current.value = await linkPreviewFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture; AppDigitalIdNative.current.value = await digitalIdNativeFuture; runApp( @@ -245,10 +248,13 @@ class KometAppState extends State } }); - _callIncomingSub = - CallController.instance.incomingCalls.listen(_onIncomingCall); + _callIncomingSub = CallController.instance.incomingCalls.listen( + _onIncomingCall, + ); - _sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async { + _sessionExpiredSub = api.sessionExpiredStream.listen(( + SessionExpiredException e, + ) async { if (_isLoggingOut) return; _isLoggingOut = true; @@ -318,11 +324,8 @@ class KometAppState extends State if (navState == null) return; navState.push( MaterialPageRoute( - builder: (_) => CallScreen( - name: name, - avatarUrl: avatar, - incoming: call, - ), + builder: (_) => + CallScreen(name: name, avatarUrl: avatar, incoming: call), ), ); } @@ -474,13 +477,10 @@ class KometAppState extends State WidgetsBinding.instance.endOfFrame.then((_) { if (_revealController != controller) return; - controller.forward().then( - (_) { - if (_revealController != controller) return; - _finishReveal(); - }, - onError: (_) {}, - ); + controller.forward().then((_) { + if (_revealController != controller) return; + _finishReveal(); + }, onError: (_) {}); }); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index d1fb2f0..b2bc3a4 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + enum AttachmentType { photo, video, @@ -8,6 +10,19 @@ enum AttachmentType { sticker, control, poll, + share, + call, +} + +String? decodeAttachPreview(dynamic raw) { + if (raw is String) return raw; + if (raw is List) { + try { + final bytes = List.from(raw); + return 'data:image/webp;base64,${base64Encode(bytes)}'; + } catch (_) {} + } + return null; } abstract class MessageAttachment { @@ -44,8 +59,10 @@ abstract class MessageAttachment { return ControlAttachment.fromMap(map); case 'POLL': return PollAttachment.fromMap(map); + case 'CALL': + return CallAttachment.fromMap(map); case 'SHARE': - return FileAttachment.fromMap(map); + return ShareAttachment.fromMap(map); case 'INLINE_KEYBOARD': return UnknownAttachment(map); default: @@ -77,21 +94,9 @@ class PhotoAttachment extends MessageAttachment { }) : super(type: AttachmentType.photo); factory PhotoAttachment.fromMap(Map map) { - String? previewStr; - final previewRaw = map['previewData']; - if (previewRaw is String) { - previewStr = previewRaw; - } else if (previewRaw is List) { - try { - final bytes = List.from(previewRaw); - final base64 = String.fromCharCodes(bytes); - previewStr = 'data:image/webp;base64,$base64'; - } catch (_) {} - } - return PhotoAttachment( - previewData: previewStr, - baseUrl: map['baseUrl'] as String?, + previewData: decodeAttachPreview(map['previewData']), + baseUrl: (map['baseUrl'] ?? map['url']) as String?, photoId: map['photoId'] as int?, photoToken: map['photoToken'] as String?, width: map['width'] as int?, @@ -134,20 +139,8 @@ class VideoAttachment extends MessageAttachment { }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) { - String? previewStr; - final previewRaw = map['previewData']; - if (previewRaw is String) { - previewStr = previewRaw; - } else if (previewRaw is List) { - try { - final bytes = List.from(previewRaw); - final base64 = String.fromCharCodes(bytes); - previewStr = 'data:image/webp;base64,$base64'; - } catch (_) {} - } - return VideoAttachment( - previewData: previewStr, + previewData: decodeAttachPreview(map['previewData']), baseUrl: map['baseUrl'] as String?, videoId: map['videoId'] as int?, videoToken: map['videoToken'] as String?, @@ -191,18 +184,6 @@ class AudioAttachment extends MessageAttachment { }) : super(type: AttachmentType.audio); factory AudioAttachment.fromMap(Map map) { - String? previewStr; - final previewRaw = map['previewData']; - if (previewRaw is String) { - previewStr = previewRaw; - } else if (previewRaw is List) { - try { - final bytes = List.from(previewRaw); - final base64 = String.fromCharCodes(bytes); - previewStr = 'data:image/webp;base64,$base64'; - } catch (_) {} - } - String? waveStr; final waveRaw = map['wave']; if (waveRaw is String) { @@ -210,13 +191,12 @@ class AudioAttachment extends MessageAttachment { } else if (waveRaw is List) { try { final bytes = List.from(waveRaw); - final base64 = String.fromCharCodes(bytes); - waveStr = 'data:image/webp;base64,$base64'; + waveStr = String.fromCharCodes(bytes); } catch (_) {} } return AudioAttachment( - previewData: previewStr, + previewData: decodeAttachPreview(map['previewData']), baseUrl: map['baseUrl']?.toString(), fileUrl: map['url']?.toString(), audioId: map['audioId'] as int?, @@ -245,6 +225,7 @@ class FileAttachment extends MessageAttachment { final String? fileToken; final String? name; final int? size; + final PhotoAttachment? preview; const FileAttachment({ super.previewData, @@ -254,28 +235,24 @@ class FileAttachment extends MessageAttachment { this.fileToken, this.name, this.size, + this.preview, }) : super(type: AttachmentType.file); factory FileAttachment.fromMap(Map map) { - String? previewStr; - final previewRaw = map['previewData']; - if (previewRaw is String) { - previewStr = previewRaw; - } else if (previewRaw is List) { - try { - final bytes = List.from(previewRaw); - final base64 = String.fromCharCodes(bytes); - previewStr = 'data:image/webp;base64,$base64'; - } catch (_) {} + PhotoAttachment? preview; + final previewRaw = map['preview']; + if (previewRaw is Map) { + preview = PhotoAttachment.fromMap(Map.from(previewRaw)); } return FileAttachment( - previewData: previewStr, + previewData: decodeAttachPreview(map['previewData']), baseUrl: map['baseUrl'] as String?, fileId: map['fileId'] as int?, - fileToken: map['fileToken'] as String?, + fileToken: (map['fileToken'] ?? map['token'])?.toString(), name: map['name'] as String?, size: map['size'] as int?, + preview: preview, ); } @@ -288,6 +265,7 @@ class FileAttachment extends MessageAttachment { 'fileToken': fileToken, 'name': name, 'size': size, + if (preview != null) 'preview': preview!.toMap(), }; } @@ -308,20 +286,8 @@ class StickerAttachment extends MessageAttachment { }) : super(type: AttachmentType.sticker); factory StickerAttachment.fromMap(Map map) { - String? previewStr; - final previewRaw = map['previewData']; - if (previewRaw is String) { - previewStr = previewRaw; - } else if (previewRaw is List) { - try { - final bytes = List.from(previewRaw); - final base64 = String.fromCharCodes(bytes); - previewStr = 'data:image/webp;base64,$base64'; - } catch (_) {} - } - return StickerAttachment( - previewData: previewStr, + previewData: decodeAttachPreview(map['previewData']), baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), stickerId: map['stickerId']?.toString(), stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), @@ -396,6 +362,7 @@ class ContactAttachment extends MessageAttachment { class LocationAttachment extends MessageAttachment { final double? latitude; final double? longitude; + final double? zoom; final String? title; final String? address; @@ -405,6 +372,7 @@ class LocationAttachment extends MessageAttachment { super.fileUrl, this.latitude, this.longitude, + this.zoom, this.title, this.address, }) : super(type: AttachmentType.location); @@ -415,6 +383,7 @@ class LocationAttachment extends MessageAttachment { baseUrl: map['baseUrl'] as String?, latitude: (map['latitude'] as num?)?.toDouble(), longitude: (map['longitude'] as num?)?.toDouble(), + zoom: (map['zoom'] as num?)?.toDouble(), title: map['title'] as String?, address: map['address'] as String?, ); @@ -427,6 +396,7 @@ class LocationAttachment extends MessageAttachment { 'baseUrl': baseUrl, 'latitude': latitude, 'longitude': longitude, + 'zoom': zoom, 'title': title, 'address': address, }; @@ -501,6 +471,103 @@ class PollAttachment extends MessageAttachment { }; } +class CallAttachment extends MessageAttachment { + final bool isVideo; + final int durationMs; + final String? hangupType; + final String? conversationId; + final String? joinLink; + final List contactIds; + + const CallAttachment({ + required this.isVideo, + this.durationMs = 0, + this.hangupType, + this.conversationId, + this.joinLink, + this.contactIds = const [], + }) : super(type: AttachmentType.call); + + bool get isGroup => joinLink != null; + + bool get isMissedOrFailed => + durationMs == 0 || + hangupType == 'CANCELED' || + hangupType == 'REJECTED' || + hangupType == 'MISSED'; + + factory CallAttachment.fromMap(Map map) { + return CallAttachment( + isVideo: (map['callType']?.toString().toUpperCase() == 'VIDEO'), + durationMs: (map['duration'] as num?)?.toInt() ?? 0, + hangupType: map['hangupType']?.toString(), + conversationId: map['conversationId']?.toString(), + joinLink: map['joinLink']?.toString(), + contactIds: (map['contactIds'] as List?) + ?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0) + .toList() ?? + const [], + ); + } + + @override + Map toMap() => { + '_type': 'CALL', + 'callType': isVideo ? 'VIDEO' : 'AUDIO', + 'duration': durationMs, + 'hangupType': hangupType, + 'conversationId': conversationId, + if (joinLink != null) 'joinLink': joinLink, + if (contactIds.isNotEmpty) 'contactIds': contactIds, + }; +} + +class ShareAttachment extends MessageAttachment { + final int? shareId; + final String? title; + final String? description; + final String? url; + final String? host; + final PhotoAttachment? image; + + const ShareAttachment({ + this.shareId, + this.title, + this.description, + this.url, + this.host, + this.image, + }) : super(type: AttachmentType.share); + + factory ShareAttachment.fromMap(Map map) { + PhotoAttachment? image; + final imageRaw = map['image']; + if (imageRaw is Map) { + image = PhotoAttachment.fromMap(Map.from(imageRaw)); + } + + return ShareAttachment( + shareId: map['shareId'] as int?, + title: map['title']?.toString(), + description: map['description']?.toString(), + url: map['url']?.toString(), + host: map['host']?.toString(), + image: image, + ); + } + + @override + Map toMap() => { + '_type': 'SHARE', + 'shareId': shareId, + 'title': title, + 'description': description, + 'url': url, + 'host': host, + if (image != null) 'image': image!.toMap(), + }; +} + class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; diff --git a/lib/models/poll.dart b/lib/models/poll.dart index 0f9cc3d..a4e5945 100644 --- a/lib/models/poll.dart +++ b/lib/models/poll.dart @@ -4,6 +4,7 @@ class PollAnswer { final int voteCount; final double rate; final List votes; + final bool mine; const PollAnswer({ required this.answerId, @@ -11,6 +12,7 @@ class PollAnswer { this.voteCount = 0, this.rate = 0, this.votes = const [], + this.mine = false, }); } @@ -35,8 +37,36 @@ class Poll { bool get isMultiple => settings & 0x1 != 0; + bool get hasMyVote => answers.any((a) => a.mine); + bool votedBy(int userId) => - answers.any((a) => a.votes.contains(userId)); + answers.any((a) => a.mine || a.votes.contains(userId)); + + static List _parseVoterIds(dynamic votes) { + if (votes is! List) return const []; + final ids = []; + for (final v in votes) { + if (v is int) { + ids.add(v); + } else if (v is Map && v['userId'] is int) { + ids.add(v['userId'] as int); + } + } + return ids; + } + + Poll withStateMap(Map stateMap) { + return Poll.fromServerMap({ + 'pollId': pollId, + 'title': title, + 'settings': settings, + 'version': version, + 'answers': [ + for (final a in answers) {'answerId': a.answerId, 'text': a.text}, + ], + 'state': stateMap, + }); + } factory Poll.fromServerMap(Map map) { final state = map['state']; @@ -64,10 +94,8 @@ class Poll { text: a['text']?.toString() ?? '', voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0, rate: (res?['rate'] as num?)?.toDouble() ?? 0, - votes: (res?['votes'] as List?) - ?.whereType() - .toList() ?? - const [], + votes: _parseVoterIds(res?['votes']), + mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0, )); } } diff --git a/pubspec.lock b/pubspec.lock index c150e8c..326cb7e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1154,6 +1154,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a333c02..da072b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.0+11 +version: 0.5.0+12 environment: sdk: ^3.10.4 @@ -61,6 +61,7 @@ dependencies: cached_network_image: ^3.4.1 path_provider: ^2.1.4 open_filex: ^4.5.0 + url_launcher: ^6.3.1 video_player: ^2.9.2 firebase_core: ^4.1.1 firebase_messaging: ^16.0.2