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:
@@ -138,3 +138,4 @@ agents.md
|
||||
komet.txt
|
||||
original_app.txt
|
||||
fingerprint.py
|
||||
PCAPdroid_*.txt
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user