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:
klockky
2026-06-11 17:14:36 +03:00
parent 760cdcd877
commit e3635b5d29
14 changed files with 1085 additions and 145 deletions
+33 -5
View File
@@ -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,
));
}
}