feat: поддержка медиа-вложений — опросы, видео, файлы и просмотр фото

Опросы (новый тип сообщения):
- модель Poll/PollAnswer и PollAttachment (_type: POLL)
- PollsModule: загрузка через opcode 306 (GET_POLL_UPDATES) с кэшем
- рендер опроса в баблах (вопрос, варианты, прогресс-бары, голоса)

Видео:
- getVideoUrl через opcode 83 (VIDEO_PLAY): выбор MP4_*/HLS из ответа
- полноэкранный плеер (video_player) с play/pause и перемоткой
- тап по видео в чате запускает воспроизведение

Файлы:
- getFileUrl через opcode 88 (FILE_DOWNLOAD) с корректным форматом
  {messageId, chatId, fileId} → url (раньше слался неверный {url, token})
- скачивание во временную папку и открытие системным приложением
  (path_provider, open_filex)

Фото:
- полноэкранный просмотрщик с зумом (был TODO-заглушкой)
- фикс «сжатости»: декодирование с учётом devicePixelRatio вместо ×2
This commit is contained in:
klockky
2026-06-02 20:06:46 +00:00
parent 1bcdb180af
commit ef05c9eaaa
13 changed files with 787 additions and 33 deletions
+1
View File
@@ -138,3 +138,4 @@ agents.md
komet.txt
original_app.txt
fingerprint.py
PCAPdroid_*.txt
+50 -21
View File
@@ -568,6 +568,43 @@ class MessagesModule {
}
}
/// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
/// Возвращает лучший доступный progressive-MP4 (или HLS как запасной).
Future<String?> getVideoUrl({
required String messageId,
required int chatId,
required String token,
required int videoId,
}) async {
try {
final response = await _api.sendRequest(Opcode.videoPlay, {
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'token': token,
'videoId': videoId,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240'];
for (final key in mp4Keys) {
final url = data[key];
if (url is String && url.isNotEmpty) return url;
}
final hls = data['HLS'];
if (hls is String && hls.isNotEmpty) return hls;
final external = data['EXTERNAL'];
if (external is String && external.isNotEmpty) return external;
return null;
} catch (_) {
return null;
}
}
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -588,23 +625,6 @@ class MessagesModule {
}
}
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl,
'token': videoToken,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
return data['content'] as String?;
} catch (e) {
return null;
}
}
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -625,18 +645,27 @@ class MessagesModule {
}
}
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
Future<String?> getFileUrl({
required String messageId,
required int chatId,
required int fileId,
}) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl,
'token': fileToken,
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'fileId': fileId,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
return data['content'] as String?;
return data['url'] as String?;
} catch (e) {
return null;
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../models/poll.dart';
class PollsModule extends ChangeNotifier {
final Api _api;
PollsModule(this._api);
final Map<int, Poll> _cache = {};
final Set<int> _inFlight = {};
Poll? get(int pollId) => _cache[pollId];
Future<void> fetch(
int chatId,
String messageId,
int pollId, {
bool force = false,
}) async {
if (pollId == 0) return;
if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) {
return;
}
_inFlight.add(pollId);
try {
final mid = int.tryParse(messageId) ?? 0;
final response = await _api.sendRequest(Opcode.getPollUpdates, {
'chatId': chatId,
'polls': [
{'messageId': mid, 'pollId': pollId},
],
});
if (!response.isOk) return;
final data = response.payload;
if (data is! Map) return;
final polls = data['polls'];
if (polls is! List) return;
var changed = false;
for (final p in polls) {
if (p is Map) {
final poll = Poll.fromServerMap(p);
if (poll.pollId != 0) {
_cache[poll.pollId] = poll;
changed = true;
}
}
}
if (changed) notifyListeners();
} catch (_) {
// тихо игнорируем — опрос просто не отобразится
} finally {
_inFlight.remove(pollId);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:open_filex/open_filex.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class FileDownloadResult {
final bool ok;
final String? path;
final String? error;
const FileDownloadResult({required this.ok, this.path, this.error});
}
/// Скачивает файл по [url] во временную папку под именем [fileName]
/// и открывает его системным приложением.
Future<FileDownloadResult> downloadAndOpenFile(
String url,
String fileName,
) async {
try {
final dir = await getTemporaryDirectory();
final safeName = _sanitize(fileName);
final file = File(p.join(dir.path, safeName));
final client = HttpClient();
try {
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) {
return FileDownloadResult(ok: false, error: 'HTTP ${response.statusCode}');
}
final sink = file.openWrite();
await response.pipe(sink);
} finally {
client.close();
}
final opened = await OpenFilex.open(file.path);
return FileDownloadResult(
ok: opened.type == ResultType.done,
path: file.path,
error: opened.type == ResultType.done ? null : opened.message,
);
} catch (e) {
return FileDownloadResult(ok: false, error: e.toString());
}
}
String _sanitize(String name) {
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
return cleaned.isEmpty ? 'file' : cleaned;
}
+122 -9
View File
@@ -8,7 +8,12 @@ import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.dart';
import '../../core/utils/haptics.dart';
import '../../core/utils/file_download.dart';
import 'custom_notification.dart';
import '../../models/attachment.dart';
import 'poll_view.dart';
import 'photo_viewer.dart';
import 'video_player_screen.dart';
enum MessageType { text, attachment, voice, control }
@@ -745,6 +750,11 @@ class MessageBubble extends StatelessWidget {
return _buildContactAttachment(ctx, contacts.first);
}
final polls = attachments.whereType<PollAttachment>().toList();
if (polls.isNotEmpty) {
return _buildPollAttachment(ctx, polls.first);
}
final photos = attachments.whereType<PhotoAttachment>().toList();
if (photos.isEmpty) {
return _buildGenericAttachment(ctx, attachments.first);
@@ -753,6 +763,21 @@ class MessageBubble extends StatelessWidget {
return _buildPhotoContent(ctx, photos);
}
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,
),
);
}
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
final hasCaption = message.text != null && message.text!.isNotEmpty;
final count = photos.length;
@@ -974,6 +999,7 @@ class MessageBubble extends StatelessWidget {
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
final matchTop = ctx.hasPhotoWithCaption;
final matchBottom = !ctx.hasPhotoWithCaption;
@@ -999,8 +1025,8 @@ class MessageBubble extends StatelessWidget {
width: constrainedWidth,
height: constrainedHeight,
fit: BoxFit.cover,
memCacheWidth: (constrainedWidth * 2).round(),
memCacheHeight: (constrainedHeight * 2).round(),
memCacheWidth: (constrainedWidth * dpr).round(),
memCacheHeight: (constrainedHeight * dpr).round(),
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
ctx.cs,
@@ -1095,6 +1121,8 @@ class MessageBubble extends StatelessWidget {
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
@@ -1105,8 +1133,8 @@ class MessageBubble extends StatelessWidget {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 280,
memCacheHeight: 280,
memCacheWidth: cachePx,
memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100),
@@ -1130,6 +1158,8 @@ class MessageBubble extends StatelessWidget {
String overlay,
) {
final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio(
aspectRatio: 1,
child: Stack(
@@ -1140,8 +1170,8 @@ class MessageBubble extends StatelessWidget {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 280,
memCacheHeight: 280,
memCacheWidth: cachePx,
memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100),
@@ -1230,10 +1260,22 @@ class MessageBubble extends StatelessWidget {
color: ctx.cs.onSurfaceVariant,
),
),
Center(
child: Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Symbols.play_arrow,
color: Colors.white, size: 30),
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
onTap: () => _playVideo(ctx.context, video),
),
),
],
@@ -1245,6 +1287,36 @@ class MessageBubble extends StatelessWidget {
);
}
Future<void> _playVideo(
BuildContext context,
MessageAttachment video,
) async {
final videoId = (video as dynamic).videoId as int?;
final token = (video as dynamic).videoToken as String?;
if (videoId == null || token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
final url = await messagesModule.getVideoUrl(
messageId: message.id,
chatId: message.chatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (url == null) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => VideoPlayerScreen(url: url),
),
);
}
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0;
@@ -1307,7 +1379,7 @@ class MessageBubble extends StatelessWidget {
),
const SizedBox(width: 12),
GestureDetector(
onTap: () {},
onTap: () => _downloadFile(ctx.context, file, name),
child: Container(
width: 34,
height: 34,
@@ -1605,7 +1677,48 @@ class MessageBubble extends StatelessWidget {
}
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
// TODO: Open photo viewer
final url = photo.baseUrl ?? '';
if (url.isEmpty) return;
Navigator.of(ctx).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoViewerScreen(baseUrl: url),
),
);
}
Future<void> _downloadFile(
BuildContext context,
MessageAttachment file,
String name,
) async {
final fileId = (file as dynamic).fileId as int?;
if (fileId == null) {
showCustomNotification(context, 'Не удалось определить файл');
return;
}
Haptics.tap();
showCustomNotification(context, 'Скачивание «$name»…');
final url = await messagesModule.getFileUrl(
messageId: message.id,
chatId: message.chatId,
fileId: fileId,
);
if (!context.mounted) return;
if (url == null) {
showCustomNotification(context, 'Не удалось получить файл');
return;
}
final result = await downloadAndOpenFile(url, name);
if (!context.mounted) return;
if (!result.ok) {
showCustomNotification(
context,
'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}',
);
}
}
Widget _buildVoiceContent(_BubbleCtx ctx) {
+54
View File
@@ -0,0 +1,54 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class PhotoViewerScreen extends StatelessWidget {
final String baseUrl;
const PhotoViewerScreen({super.key, required this.baseUrl});
String get _url => baseUrl;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: InteractiveViewer(
minScale: 1,
maxScale: 5,
child: Center(
child: _url.isEmpty
? const Icon(Symbols.broken_image,
color: Colors.white54, size: 64)
: CachedNetworkImage(
imageUrl: _url,
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (_, _, _) => const Icon(
Symbols.broken_image,
color: Colors.white54,
size: 64,
),
),
),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: IconButton(
icon: const Icon(Symbols.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import '../../main.dart';
import '../../models/poll.dart';
class PollView extends StatefulWidget {
final int chatId;
final String messageId;
final int pollId;
final String? fallbackTitle;
final Color textColor;
final Color dimColor;
final Color accentColor;
const PollView({
super.key,
required this.chatId,
required this.messageId,
required this.pollId,
required this.textColor,
required this.dimColor,
required this.accentColor,
this.fallbackTitle,
});
@override
State<PollView> createState() => _PollViewState();
}
class _PollViewState extends State<PollView> {
@override
void initState() {
super.initState();
pollsModule.fetch(widget.chatId, widget.messageId, widget.pollId);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: pollsModule,
builder: (context, _) {
final poll = pollsModule.get(widget.pollId);
return _buildCard(poll);
},
);
}
Widget _buildCard(Poll? poll) {
final title = poll?.title.isNotEmpty == true
? poll!.title
: (widget.fallbackTitle ?? 'Опрос');
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: 220, maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
color: widget.textColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
poll == null
? 'Загрузка опроса…'
: _votesLabel(poll.total),
style: TextStyle(color: widget.dimColor, fontSize: 12),
),
const SizedBox(height: 10),
if (poll != null)
...poll.answers.map((a) => _buildAnswer(a, poll.total)),
],
),
);
}
Widget _buildAnswer(PollAnswer answer, int total) {
final pct = total > 0 ? answer.voteCount / total : 0.0;
final pctLabel = '${(pct * 100).round()}%';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
answer.text,
style: TextStyle(color: widget.textColor, fontSize: 14),
),
),
const SizedBox(width: 8),
Text(
pctLabel,
style: TextStyle(
color: widget.dimColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct,
minHeight: 6,
backgroundColor: widget.dimColor.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(widget.accentColor),
),
),
],
),
);
}
String _votesLabel(int total) {
if (total == 0) return 'Нет голосов';
final mod10 = total % 10;
final mod100 = total % 100;
String word;
if (mod10 == 1 && mod100 != 11) {
word = 'голос';
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
word = 'голоса';
} else {
word = 'голосов';
}
return '$total $word';
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerScreen extends StatefulWidget {
final String url;
const VideoPlayerScreen({super.key, required this.url});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
VideoPlayerController? _controller;
bool _error = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
_controller = controller;
try {
await controller.initialize();
if (!mounted) return;
setState(() {});
controller.play();
controller.addListener(_onTick);
} catch (_) {
if (mounted) setState(() => _error = true);
}
}
void _onTick() {
if (mounted) setState(() {});
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
void _togglePlay() {
final c = _controller;
if (c == null || !c.value.isInitialized) return;
setState(() => c.value.isPlaying ? c.pause() : c.play());
}
@override
Widget build(BuildContext context) {
final c = _controller;
final ready = c != null && c.value.isInitialized;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Center(
child: _error
? const Icon(Symbols.error, color: Colors.white54, size: 64)
: ready
? AspectRatio(
aspectRatio: c.value.aspectRatio,
child: VideoPlayer(c),
)
: const CircularProgressIndicator(color: Colors.white),
),
if (ready)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _togglePlay,
child: AnimatedOpacity(
opacity: c.value.isPlaying ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Symbols.play_arrow,
color: Colors.white, size: 40),
),
),
),
),
),
if (ready)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: VideoProgressIndicator(
c,
allowScrubbing: true,
colors: const VideoProgressColors(playedColor: Colors.white),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: IconButton(
icon: const Icon(Symbols.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
);
}
}
+2
View File
@@ -28,6 +28,7 @@ import 'backend/modules/chats.dart';
import 'backend/modules/contacts.dart';
import 'backend/modules/file_uploader.dart';
import 'backend/modules/messages.dart';
import 'backend/modules/polls.dart';
import 'core/push/push_service.dart';
import 'core/storage/app_database.dart';
import 'core/transport/tls_config.dart';
@@ -44,6 +45,7 @@ import 'frontend/widgets/theme_reveal.dart';
final api = Api();
final accountModule = AccountModule(api);
final messagesModule = MessagesModule(api);
final pollsModule = PollsModule(api);
final fileUploader = FileUploader(api: api, messages: messagesModule);
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
RouteObserver<PageRoute<dynamic>>();
+28
View File
@@ -7,6 +7,7 @@ enum AttachmentType {
location,
sticker,
control,
poll,
}
abstract class MessageAttachment {
@@ -41,6 +42,8 @@ abstract class MessageAttachment {
return LocationAttachment.fromMap(map);
case 'CONTROL':
return ControlAttachment.fromMap(map);
case 'POLL':
return PollAttachment.fromMap(map);
case 'SHARE':
return FileAttachment.fromMap(map);
case 'INLINE_KEYBOARD':
@@ -471,6 +474,31 @@ class ControlAttachment extends MessageAttachment {
};
}
class PollAttachment extends MessageAttachment {
final int pollId;
final String? title;
const PollAttachment({
required this.pollId,
this.title,
}) : super(type: AttachmentType.poll);
factory PollAttachment.fromMap(Map<String, dynamic> map) {
final id = map['pollId'] ?? map['id'];
return PollAttachment(
pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0,
title: (map['title'] ?? map['question'])?.toString(),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'POLL',
'pollId': pollId,
'title': title,
};
}
class ForwardedMessageAttachment extends MessageAttachment {
final int originalSenderId;
final String? originalSenderName;
+87
View File
@@ -0,0 +1,87 @@
class PollAnswer {
final int answerId;
final String text;
final int voteCount;
final double rate;
final List<int> votes;
const PollAnswer({
required this.answerId,
required this.text,
this.voteCount = 0,
this.rate = 0,
this.votes = const [],
});
}
class Poll {
final int pollId;
final String title;
final int settings;
final int version;
final int total;
final List<PollAnswer> answers;
final List<int> voterPreviewIds;
const Poll({
required this.pollId,
required this.title,
this.settings = 0,
this.version = 0,
this.total = 0,
this.answers = const [],
this.voterPreviewIds = const [],
});
bool get isMultiple => settings & 0x1 != 0;
bool votedBy(int userId) =>
answers.any((a) => a.votes.contains(userId));
factory Poll.fromServerMap(Map<dynamic, dynamic> map) {
final state = map['state'];
final stateMap = state is Map ? state : const {};
final resultsById = <int, Map>{};
final result = stateMap['result'];
if (result is List) {
for (final r in result) {
if (r is Map && r['answerId'] is int) {
resultsById[r['answerId'] as int] = r;
}
}
}
final answers = <PollAnswer>[];
final rawAnswers = map['answers'];
if (rawAnswers is List) {
for (final a in rawAnswers) {
if (a is! Map) continue;
final id = a['answerId'] as int? ?? 0;
final res = resultsById[id];
answers.add(PollAnswer(
answerId: id,
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 [],
));
}
}
return Poll(
pollId: map['pollId'] as int? ?? 0,
title: map['title']?.toString() ?? '',
settings: map['settings'] as int? ?? 0,
version: map['version'] as int? ?? 0,
total: (stateMap['total'] as num?)?.toInt() ?? 0,
answers: answers,
voterPreviewIds:
(stateMap['voterPreviewIds'] as List?)?.whereType<int>().toList() ??
const [],
);
}
}
+67 -3
View File
@@ -137,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_lz4:
dependency: "direct main"
description:
@@ -397,6 +405,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@@ -613,6 +629,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
open_filex:
dependency: "direct main"
description:
name: open_filex
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
package_info_plus:
dependency: "direct main"
description:
@@ -638,7 +662,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -970,6 +994,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "5d18d04084cc0cfc7afde39d0a308d4041e8ae6e9d5255bc086c263998dd1201"
url: "https://pub.dev"
source: hosted
version: "2.9.6"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
url: "https://pub.dev"
source: hosted
version: "2.9.7"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
@@ -1027,5 +1091,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+3
View File
@@ -57,6 +57,9 @@ dependencies:
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1
path_provider: ^2.1.4
open_filex: ^4.5.0
video_player: ^2.9.2
firebase_core: ^4.1.1
firebase_messaging: ^16.0.2
flutter_local_notifications: ^21.0.0