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
|
komet.txt
|
||||||
original_app.txt
|
original_app.txt
|
||||||
fingerprint.py
|
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 {
|
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
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 {
|
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
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 {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
'url': baseUrl,
|
'messageId': int.tryParse(messageId) ?? 0,
|
||||||
'token': fileToken,
|
'chatId': chatId,
|
||||||
|
'fileId': fileId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.isOk) return null;
|
if (!response.isOk) return null;
|
||||||
final data = response.payload;
|
final data = response.payload;
|
||||||
if (data is! Map) return null;
|
if (data is! Map) return null;
|
||||||
|
|
||||||
return data['content'] as String?;
|
return data['url'] as String?;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
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/config/app_bubble_shape.dart';
|
||||||
import '../../core/utils/bubble_radius.dart';
|
import '../../core/utils/bubble_radius.dart';
|
||||||
import '../../core/utils/haptics.dart';
|
import '../../core/utils/haptics.dart';
|
||||||
|
import '../../core/utils/file_download.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
|
import 'poll_view.dart';
|
||||||
|
import 'photo_viewer.dart';
|
||||||
|
import 'video_player_screen.dart';
|
||||||
|
|
||||||
enum MessageType { text, attachment, voice, control }
|
enum MessageType { text, attachment, voice, control }
|
||||||
|
|
||||||
@@ -745,6 +750,11 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return _buildContactAttachment(ctx, contacts.first);
|
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();
|
final photos = attachments.whereType<PhotoAttachment>().toList();
|
||||||
if (photos.isEmpty) {
|
if (photos.isEmpty) {
|
||||||
return _buildGenericAttachment(ctx, attachments.first);
|
return _buildGenericAttachment(ctx, attachments.first);
|
||||||
@@ -753,6 +763,21 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return _buildPhotoContent(ctx, photos);
|
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) {
|
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
|
||||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||||
final count = photos.length;
|
final count = photos.length;
|
||||||
@@ -974,6 +999,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
||||||
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
||||||
|
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||||||
|
|
||||||
final matchTop = ctx.hasPhotoWithCaption;
|
final matchTop = ctx.hasPhotoWithCaption;
|
||||||
final matchBottom = !ctx.hasPhotoWithCaption;
|
final matchBottom = !ctx.hasPhotoWithCaption;
|
||||||
@@ -999,8 +1025,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
width: constrainedWidth,
|
width: constrainedWidth,
|
||||||
height: constrainedHeight,
|
height: constrainedHeight,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: (constrainedWidth * 2).round(),
|
memCacheWidth: (constrainedWidth * dpr).round(),
|
||||||
memCacheHeight: (constrainedHeight * 2).round(),
|
memCacheHeight: (constrainedHeight * dpr).round(),
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
||||||
ctx.cs,
|
ctx.cs,
|
||||||
@@ -1095,6 +1121,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
final imageUrl = photo.baseUrl ?? '';
|
||||||
|
final cachePx =
|
||||||
|
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1105,8 +1133,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
memCacheWidth: 280,
|
memCacheWidth: cachePx,
|
||||||
memCacheHeight: 280,
|
memCacheHeight: cachePx,
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) =>
|
errorWidget: (_, _, _) =>
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||||
@@ -1130,6 +1158,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
String overlay,
|
String overlay,
|
||||||
) {
|
) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
final imageUrl = photo.baseUrl ?? '';
|
||||||
|
final cachePx =
|
||||||
|
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1140,8 +1170,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
memCacheWidth: 280,
|
memCacheWidth: cachePx,
|
||||||
memCacheHeight: 280,
|
memCacheHeight: cachePx,
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) =>
|
errorWidget: (_, _, _) =>
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||||
@@ -1230,10 +1260,22 @@ class MessageBubble extends StatelessWidget {
|
|||||||
color: ctx.cs.onSurfaceVariant,
|
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(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
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) {
|
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
|
||||||
final name = (file as dynamic).name as String? ?? 'File';
|
final name = (file as dynamic).name as String? ?? 'File';
|
||||||
final size = (file as dynamic).size as int? ?? 0;
|
final size = (file as dynamic).size as int? ?? 0;
|
||||||
@@ -1307,7 +1379,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {},
|
onTap: () => _downloadFile(ctx.context, file, name),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
@@ -1605,7 +1677,48 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
|
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) {
|
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/contacts.dart';
|
||||||
import 'backend/modules/file_uploader.dart';
|
import 'backend/modules/file_uploader.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
|
import 'backend/modules/polls.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
import 'core/transport/tls_config.dart';
|
import 'core/transport/tls_config.dart';
|
||||||
@@ -44,6 +45,7 @@ import 'frontend/widgets/theme_reveal.dart';
|
|||||||
final api = Api();
|
final api = Api();
|
||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
final messagesModule = MessagesModule(api);
|
final messagesModule = MessagesModule(api);
|
||||||
|
final pollsModule = PollsModule(api);
|
||||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||||
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
||||||
RouteObserver<PageRoute<dynamic>>();
|
RouteObserver<PageRoute<dynamic>>();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum AttachmentType {
|
|||||||
location,
|
location,
|
||||||
sticker,
|
sticker,
|
||||||
control,
|
control,
|
||||||
|
poll,
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MessageAttachment {
|
abstract class MessageAttachment {
|
||||||
@@ -41,6 +42,8 @@ abstract class MessageAttachment {
|
|||||||
return LocationAttachment.fromMap(map);
|
return LocationAttachment.fromMap(map);
|
||||||
case 'CONTROL':
|
case 'CONTROL':
|
||||||
return ControlAttachment.fromMap(map);
|
return ControlAttachment.fromMap(map);
|
||||||
|
case 'POLL':
|
||||||
|
return PollAttachment.fromMap(map);
|
||||||
case 'SHARE':
|
case 'SHARE':
|
||||||
return FileAttachment.fromMap(map);
|
return FileAttachment.fromMap(map);
|
||||||
case 'INLINE_KEYBOARD':
|
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 {
|
class ForwardedMessageAttachment extends MessageAttachment {
|
||||||
final int originalSenderId;
|
final int originalSenderId;
|
||||||
final String? originalSenderName;
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
dart_lz4:
|
dart_lz4:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -397,6 +405,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -613,6 +629,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -638,7 +662,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -970,6 +994,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1027,5 +1091,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.4 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ dependencies:
|
|||||||
package_info_plus: ^9.0.1
|
package_info_plus: ^9.0.1
|
||||||
mobile_scanner: ^7.2.0
|
mobile_scanner: ^7.2.0
|
||||||
cached_network_image: ^3.4.1
|
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_core: ^4.1.1
|
||||||
firebase_messaging: ^16.0.2
|
firebase_messaging: ^16.0.2
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user