feat: полное отображение типов сообщений + предпросмотр ссылок и оптимизация фото
Модели (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
This commit is contained in:
@@ -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<List<CallLogEntry>> fetchHistory(
|
||||
int accountId,
|
||||
|
||||
@@ -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<MessageEvent>.broadcast();
|
||||
static Stream<MessageEvent> 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<String, dynamic>.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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,34 @@ class PollsModule extends ChangeNotifier {
|
||||
_inFlight.remove(pollId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> vote(
|
||||
int chatId,
|
||||
String messageId,
|
||||
int pollId,
|
||||
List<int> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../frontend/widgets/custom_notification.dart';
|
||||
|
||||
Future<void> 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<void> 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',
|
||||
);
|
||||
}
|
||||
@@ -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<DebugMenuScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
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),
|
||||
|
||||
@@ -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<LinkText> createState() => _LinkTextState();
|
||||
}
|
||||
|
||||
class _LinkTextState extends State<LinkText> {
|
||||
final List<TapGestureRecognizer> _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 = <InlineSpan>[];
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<bool>(
|
||||
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<ShareAttachment>().toList();
|
||||
if (shares.isNotEmpty) {
|
||||
return _buildShareContent(ctx, shares.first);
|
||||
}
|
||||
|
||||
final photos = attachments.whereType<PhotoAttachment>().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)],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<PollView> {
|
||||
final Set<int> _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<void> _vote(List<int> 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<PollView> {
|
||||
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<PollView> {
|
||||
),
|
||||
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<PollView> {
|
||||
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<PollView> {
|
||||
}
|
||||
|
||||
String _votesLabel(int total) {
|
||||
if (total == 0) return 'Нет голосов';
|
||||
final mod10 = total % 10;
|
||||
final mod100 = total % 100;
|
||||
String word;
|
||||
|
||||
+15
-15
@@ -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<KometApp>
|
||||
}
|
||||
});
|
||||
|
||||
_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<KometApp>
|
||||
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<KometApp>
|
||||
|
||||
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: (_) {});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+135
-68
@@ -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<int>.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<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.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<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.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<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.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<int>.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<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.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<String, dynamic>.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<String, dynamic> map) {
|
||||
String? previewStr;
|
||||
final previewRaw = map['previewData'];
|
||||
if (previewRaw is String) {
|
||||
previewStr = previewRaw;
|
||||
} else if (previewRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.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<int> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> map) {
|
||||
PhotoAttachment? image;
|
||||
final imageRaw = map['image'];
|
||||
if (imageRaw is Map) {
|
||||
image = PhotoAttachment.fromMap(Map<String, dynamic>.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<String, dynamic> 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;
|
||||
|
||||
+33
-5
@@ -4,6 +4,7 @@ class PollAnswer {
|
||||
final int voteCount;
|
||||
final double rate;
|
||||
final List<int> 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<int> _parseVoterIds(dynamic votes) {
|
||||
if (votes is! List) return const [];
|
||||
final ids = <int>[];
|
||||
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<dynamic, dynamic> 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<dynamic, dynamic> 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<int>()
|
||||
.toList() ??
|
||||
const [],
|
||||
votes: _parseVoterIds(res?['votes']),
|
||||
mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user