From ef05c9eaaa158ccf695f7c2c0a2bfb55df6c72f3 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 20:06:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0-=D0=B2?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=E2=80=94=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=8B,=20=D0=B2=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BE,=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D1=84=D0=BE=D1=82?= =?UTF-8?q?=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Опросы (новый тип сообщения): - модель 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 --- .gitignore | 1 + lib/backend/modules/messages.dart | 71 ++++++--- lib/backend/modules/polls.dart | 61 ++++++++ lib/core/utils/file_download.dart | 53 +++++++ lib/frontend/widgets/message_bubble.dart | 131 +++++++++++++++-- lib/frontend/widgets/photo_viewer.dart | 54 +++++++ lib/frontend/widgets/poll_view.dart | 139 ++++++++++++++++++ lib/frontend/widgets/video_player_screen.dart | 120 +++++++++++++++ lib/main.dart | 2 + lib/models/attachment.dart | 28 ++++ lib/models/poll.dart | 87 +++++++++++ pubspec.lock | 70 ++++++++- pubspec.yaml | 3 + 13 files changed, 787 insertions(+), 33 deletions(-) create mode 100644 lib/backend/modules/polls.dart create mode 100644 lib/core/utils/file_download.dart create mode 100644 lib/frontend/widgets/photo_viewer.dart create mode 100644 lib/frontend/widgets/poll_view.dart create mode 100644 lib/frontend/widgets/video_player_screen.dart create mode 100644 lib/models/poll.dart diff --git a/.gitignore b/.gitignore index d86649d..5509b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ agents.md komet.txt original_app.txt fingerprint.py +PCAPdroid_*.txt diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 7a29566..6108dcb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -568,6 +568,43 @@ class MessagesModule { } } + /// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, + /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. + /// Возвращает лучший доступный progressive-MP4 (или HLS как запасной). + Future 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 downloadVideo(String baseUrl, String videoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -588,23 +625,6 @@ class MessagesModule { } } - Future 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 downloadFile(String baseUrl, String fileToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -625,18 +645,27 @@ class MessagesModule { } } - Future getFileUrl(String baseUrl, String fileToken) async { + /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`, + /// ответ `{url: "https://fd.oneme.ru/getfile?..."}`. + Future 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; } diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart new file mode 100644 index 0000000..b3f9172 --- /dev/null +++ b/lib/backend/modules/polls.dart @@ -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 _cache = {}; + final Set _inFlight = {}; + + Poll? get(int pollId) => _cache[pollId]; + + Future 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); + } + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart new file mode 100644 index 0000000..b40c0d5 --- /dev/null +++ b/lib/core/utils/file_download.dart @@ -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 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; +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 3efeaae..cd76ebe 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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().toList(); + if (polls.isNotEmpty) { + return _buildPollAttachment(ctx, polls.first); + } + final photos = attachments.whereType().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 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 _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 _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) { diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart new file mode 100644 index 0000000..8cfc89e --- /dev/null +++ b/lib/frontend/widgets/photo_viewer.dart @@ -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(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart new file mode 100644 index 0000000..5a40436 --- /dev/null +++ b/lib/frontend/widgets/poll_view.dart @@ -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 createState() => _PollViewState(); +} + +class _PollViewState extends State { + @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(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'; + } +} diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart new file mode 100644 index 0000000..3382f80 --- /dev/null +++ b/lib/frontend/widgets/video_player_screen.dart @@ -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 createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + VideoPlayerController? _controller; + bool _error = false; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _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(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4ddfaca..2284455 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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> appRouteObserver = RouteObserver>(); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dd76314..4ee7718 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -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 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 toMap() => { + '_type': 'POLL', + 'pollId': pollId, + 'title': title, + }; +} + class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; diff --git a/lib/models/poll.dart b/lib/models/poll.dart new file mode 100644 index 0000000..0f9cc3d --- /dev/null +++ b/lib/models/poll.dart @@ -0,0 +1,87 @@ +class PollAnswer { + final int answerId; + final String text; + final int voteCount; + final double rate; + final List 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 answers; + final List 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 map) { + final state = map['state']; + final stateMap = state is Map ? state : const {}; + + final resultsById = {}; + 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 = []; + 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() + .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().toList() ?? + const [], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9dab85f..79013ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index edae9ba..71436b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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