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
@@ -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),
+68
View File
@@ -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));
}
}
+407 -33
View File
@@ -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)],
],
),
+141 -10
View File
@@ -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;