feat(chat): отложенные сообщения и отправка видео

This commit is contained in:
klockky
2026-06-13 08:02:12 +00:00
parent d6acfa52fa
commit c8a64e2871
6 changed files with 1262 additions and 58 deletions
+131
View File
@@ -52,6 +52,7 @@ class FileUploader {
required File file,
required String filename,
required int totalSize,
int? scheduledTime,
Duration autoForceAfter = const Duration(seconds: 1),
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
@@ -125,6 +126,7 @@ class FileUploader {
chatId,
info.fileId,
token: info.token,
scheduledTime: scheduledTime,
);
if (cancelled) return;
if (!ok) {
@@ -308,6 +310,135 @@ class FileUploader {
}
}
/// Загружает видео на CDN-URL (vu.okcdn.ru/upload.do), полученный из
/// [MessagesModule.requestVideoUploadUrl], по протоколу OK с докачкой:
/// сначала GET-хендшейк (возвращает уже загруженный оффсет), затем
/// параллельная отправка чанков по [chunkSize] байт через `Content-Range`
/// ([concurrency] одновременных соединений, режим `X-Uploading-Mode:
/// parallel`). Токен уже известен, поэтому возвращается только признак
/// успеха.
Future<bool> uploadVideoFile(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
int chunkSize = 2 * 1024 * 1024,
int concurrency = 4,
Duration overallTimeout = const Duration(minutes: 30),
}) async {
final total = await file.length();
if (total <= 0) return false;
final fileName =
(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString();
final handshake = await _okCdnRequest(
uri,
method: 'GET',
fileName: fileName,
timeout: const Duration(seconds: 30),
);
if (handshake == null || handshake.$1 != 200) return false;
var startOffset = 0;
final resumed = int.tryParse(handshake.$2.trim());
if (resumed != null && resumed > 0 && resumed <= total) {
startOffset = resumed;
}
final ranges = <(int, int)>[];
for (var o = startOffset; o < total; o += chunkSize) {
ranges.add((o, o + chunkSize < total ? o + chunkSize : total));
}
if (ranges.isEmpty) return true;
var nextIndex = 0;
var sent = startOffset;
var failed = false;
Future<void> worker() async {
while (!failed) {
final i = nextIndex++;
if (i >= ranges.length) return;
final (start, end) = ranges[i];
final bytes = await _readRange(file, start, end);
final resp = await _okCdnRequest(
uri,
method: 'POST',
fileName: fileName,
body: bytes,
contentRange: 'bytes $start-${end - 1}/$total',
timeout: overallTimeout,
);
if (resp == null || (resp.$1 != 200 && resp.$1 != 201)) {
logger.w('uploadVideoFile: chunk status=${resp?.$1}');
failed = true;
return;
}
sent += end - start;
onProgress?.call(sent, total);
}
}
final workerCount = concurrency < ranges.length
? concurrency
: ranges.length;
await Future.wait(List.generate(workerCount, (_) => worker()));
return !failed;
}
Future<Uint8List> _readRange(File file, int start, int end) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in file.openRead(start, end)) {
builder.add(chunk);
}
return builder.takeBytes();
}
Future<(int, String)?> _okCdnRequest(
Uri uri, {
required String method,
required String fileName,
Uint8List? body,
String? contentRange,
required Duration timeout,
}) async {
Socket? socket;
try {
socket = await _openSocket(uri);
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final headers = StringBuffer()
..write('$method $path HTTP/1.1\r\n')
..write('Host: ${uri.host}\r\n')
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
..write('Content-Disposition: attachment; fileName="$fileName"\r\n');
if (contentRange != null) {
headers.write('Content-Range: $contentRange\r\n');
}
headers
..write('Content-Length: ${body?.length ?? 0}\r\n')
..write('X-Uploading-Mode: parallel\r\n')
..write('Connection: close\r\n')
..write('\r\n');
socket.add(utf8.encode(headers.toString()));
if (body != null && body.isNotEmpty) socket.add(body);
await socket.flush();
final response = await _readFullResponse(socket, timeout: timeout);
try {
socket.destroy();
} catch (_) {}
return response;
} catch (e) {
logger.w('_okCdnRequest($method): $e');
try {
socket?.destroy();
} catch (_) {}
return null;
}
}
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final headers = StringBuffer()
+237 -19
View File
@@ -174,6 +174,18 @@ class FileUploadInfo {
});
}
class VideoUploadInfo {
final String url;
final int videoId;
final String token;
VideoUploadInfo({
required this.url,
required this.videoId,
required this.token,
});
}
class CachedMessage {
final String id;
final int accountId;
@@ -250,6 +262,18 @@ class CachedMessage {
);
}
int? get delayedTimeToFire {
final attrs = payload?['delayedAttributes'];
if (attrs is Map) {
final t = attrs['timeToFire'];
if (t is int) return t;
if (t is String) return int.tryParse(t);
}
return null;
}
bool get isDelayed => delayedTimeToFire != null;
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
rows.map(CachedMessage.fromDbRow).toList();
@@ -435,15 +459,23 @@ class MessagesModule {
int chatId,
String text, {
bool notify = true,
int? scheduledTime,
}) async {
final message = <String, dynamic>{
'text': text,
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'elements': [],
'attaches': [],
};
if (scheduledTime != null) {
message['delayedAttributes'] = {
'timeToFire': scheduledTime,
'notifySender': true,
};
}
final payload = {
'chatId': chatId,
'message': {
'text': text,
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'elements': [],
'attaches': [],
},
'message': message,
'notify': notify,
};
@@ -467,10 +499,107 @@ class MessagesModule {
return '';
}
/// Загружает отложенные (запланированные) сообщения чата.
///
/// В отличие от обычной истории, отложенные сообщения не сохраняются
/// в локальную БД — они живут только до момента отправки.
Future<List<CachedMessage>> fetchDelayedMessages(
int accountId,
int chatId,
) async {
final payload = {
'chatId': chatId,
'forward': 0,
'backwardTime': 0,
'getChat': false,
'from': 1,
'itemType': 'DELAYED',
'getMessages': true,
'forwardTime': 0,
'interactive': true,
'backward': 150,
};
final response = await _api.sendRequest(Opcode.chatHistory, payload);
if (!response.isOk) return [];
final data = response.payload;
if (data is! Map) return [];
final messagesData = data['messages'];
if (messagesData is! List) return [];
final results = <CachedMessage>[];
for (final m in messagesData) {
if (m is! Map) continue;
final msg = _parseMessage(m.cast<dynamic, dynamic>(), accountId, chatId);
if (msg != null) results.add(msg);
}
results.sort(
(a, b) => (a.delayedTimeToFire ?? a.time).compareTo(
b.delayedTimeToFire ?? b.time,
),
);
return results;
}
/// Редактирует текст (подпись) обычного сообщения.
///
/// Поле `attachments` не передаётся — сервер сохраняет существующие
/// вложения.
Future<bool> editMessage(
int chatId,
String messageId, {
required String text,
}) async {
final id = int.tryParse(messageId);
if (id == null) return false;
final payload = {
'messageId': id,
'chatId': chatId,
'elements': <dynamic>[],
'text': text,
};
final response = await _api.sendRequest(Opcode.msgEdit, payload);
return response.isOk;
}
/// Редактирует отложенное сообщение: меняет текст и/или время отправки.
///
/// Вложения сервер сохраняет сам — в payload они не передаются.
Future<bool> editScheduledMessage(
int chatId,
String messageId, {
required String text,
required int timeToFire,
}) async {
final id = int.tryParse(messageId);
if (id == null) return false;
final payload = {
'messageId': id,
'chatId': chatId,
'elements': <dynamic>[],
'text': text,
'delayedAttributes': {
'timeToFire': timeToFire,
'notifySender': true,
},
};
final response = await _api.sendRequest(Opcode.msgEdit, payload);
return response.isOk;
}
Future<bool> deleteMessages(
int chatId,
List<String> messageIds, {
bool forEveryone = false,
String itemType = 'REGULAR',
}) async {
final ids = messageIds
.map((id) => int.tryParse(id))
@@ -482,7 +611,7 @@ class MessagesModule {
'messageIds': ids,
'chatId': chatId,
'forMe': !forEveryone,
'itemType': 'REGULAR',
'itemType': itemType,
};
final response = await _api.sendRequest(Opcode.msgDelete, payload);
@@ -547,24 +676,32 @@ class MessagesModule {
int fileId, {
String? token,
bool notify = true,
int? scheduledTime,
int maxAttempts = 20,
Duration retryDelay = const Duration(seconds: 1),
Duration initialDelay = const Duration(seconds: 3),
}) async {
final message = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch,
'attaches': [
if (token != null)
{'_type': 'FILE', 'token': token}
else
{'_type': 'FILE', 'fileId': fileId},
],
};
if (scheduledTime != null) {
message['delayedAttributes'] = {
'timeToFire': scheduledTime,
'notifySender': true,
};
}
final payload = {
'chatId': chatId,
'message': {
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch,
'attaches': [
if (token != null)
{'_type': 'FILE', 'token': token}
else
{'_type': 'FILE', 'fileId': fileId},
],
},
'message': message,
'notify': notify,
};
@@ -597,6 +734,7 @@ class MessagesModule {
List<String> photoTokens, {
String? caption,
bool notify = true,
int? scheduledTime,
int maxAttempts = 20,
Duration retryDelay = const Duration(seconds: 1),
}) async {
@@ -608,6 +746,86 @@ class MessagesModule {
],
};
if (caption != null && caption.isNotEmpty) message['text'] = caption;
if (scheduledTime != null) {
message['delayedAttributes'] = {
'timeToFire': scheduledTime,
'notifySender': true,
};
}
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) return null;
final data = response.payload;
if (data is Map) {
final msg = data['message'];
if (msg is Map) return Map<String, dynamic>.from(msg);
}
return null;
} on PacketError catch (e) {
if (e.errorKey != 'attachment.not.ready') rethrow;
if (attempt == maxAttempts - 1) return null;
await Future.delayed(retryDelay);
}
}
return null;
}
/// Запрашивает URL для загрузки видео (опкод 82).
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 0,
'type': 0,
'count': 1,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
final infoList = data['info'] as List?;
if (infoList == null || infoList.isEmpty) return null;
final info = infoList.first;
if (info is! Map) return null;
return VideoUploadInfo(
url: info['url'] as String? ?? '',
videoId: info['videoId'] as int? ?? 0,
token: info['token'] as String? ?? '',
);
}
/// Отправляет сообщение с видео по [token], полученному из
/// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`,
/// пока обрабатывает загруженное видео — в этом случае запрос повторяется.
Future<Map<String, dynamic>?> sendVideoMessage(
int chatId,
String token, {
String? caption,
bool notify = true,
int? scheduledTime,
int maxAttempts = 30,
Duration retryDelay = const Duration(seconds: 1),
}) async {
final message = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'attaches': [
{'videoType': 0, '_type': 'VIDEO', 'token': token},
],
};
if (caption != null && caption.isNotEmpty) message['text'] = caption;
if (scheduledTime != null) {
message['delayedAttributes'] = {
'timeToFire': scheduledTime,
'notifySender': true,
};
}
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
for (var attempt = 0; attempt < maxAttempts; attempt++) {
+453 -36
View File
@@ -41,6 +41,7 @@ import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart';
import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/swipe_to_pop.dart';
import 'scheduled_messages_screen.dart';
class _UploadStatus {
final bool active;
@@ -623,6 +624,116 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_messagesRev.value++;
}
bool _canEditMessage(CachedMessage message) {
if (message.senderId != _myId) return false;
if (message.id.startsWith('temp_')) return false;
if (message.isControl) return false;
final status = message.status;
if (status == 'sending' || status == 'error') return false;
return true;
}
Future<void> _startEditMessage(CachedMessage message) async {
final cs = Theme.of(context).colorScheme;
final controller = TextEditingController(text: message.text ?? '');
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Изменить сообщение',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 6,
style: TextStyle(color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Текст сообщения',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(sheetContext).pop(true),
child: const Text('Сохранить'),
),
],
),
),
);
if (saved != true || !mounted) {
controller.dispose();
return;
}
final newText = controller.text.trim();
controller.dispose();
if (newText == (message.text ?? '')) return;
final ok = await messagesModule.editMessage(
widget.chatId,
message.id,
text: newText,
);
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось изменить сообщение');
return;
}
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx != -1) {
final old = _messages[idx];
final edited = CachedMessage(
id: old.id,
accountId: old.accountId,
chatId: old.chatId,
senderId: old.senderId,
text: newText.isEmpty ? null : newText,
time: old.time,
status: 'EDITED',
payload: old.payload,
attachments: old.attachments,
isControl: old.isControl,
);
_messages[idx] = edited;
_bumpMessages();
unawaited(_persistOutgoing(edited));
}
Haptics.send();
}
Future<void> _confirmDeleteMessage(CachedMessage message, bool isMe) async {
final isLocalOnly = message.id.startsWith('temp_');
final canForEveryone = isMe && !isLocalOnly;
@@ -894,6 +1005,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
],
),
actions: [
IconButton(
icon: const Icon(Symbols.schedule, weight: 400),
onPressed: _openScheduledMessages,
),
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: _startCall,
@@ -1094,6 +1209,78 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _scheduleMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
final when = await _pickScheduleTime();
if (when == null || !mounted) return;
try {
await messagesModule.sendMessage(
_myId,
widget.chatId,
text,
scheduledTime: when.millisecondsSinceEpoch,
);
if (!mounted) return;
_hasText.value = false;
_messageController.clear();
Haptics.send();
showCustomNotification(
context,
'Запланировано на ${formatDateTimeWords(when)}',
);
} catch (_) {
if (!mounted) return;
Haptics.error();
showCustomNotification(context, 'Не удалось запланировать сообщение');
}
}
Future<DateTime?> _pickScheduleTime() async {
final now = DateTime.now();
final suggested = now.add(const Duration(hours: 1));
final date = await showDatePicker(
context: context,
initialDate: suggested,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null || !mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(suggested),
);
if (time == null) return null;
final result = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (!result.isAfter(DateTime.now())) {
if (mounted) {
showCustomNotification(context, 'Время должно быть в будущем');
}
return null;
}
return result;
}
void _openScheduledMessages() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ScheduledMessagesScreen(
chatId: widget.chatId,
accountId: _myId,
chatName: widget.name,
),
),
);
}
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
try {
if (removeId != null && removeId != msg.id) {
@@ -1514,6 +1701,14 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Symbols.schedule,
weight: 500,
color: cs.onSurface,
),
onPressed: _openScheduledMessages,
),
IconButton(
icon: Icon(
Symbols.call,
@@ -1658,6 +1853,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
message: message,
isMe: isMe,
onDelete: () => _confirmDeleteMessage(message, isMe),
onEdit: _canEditMessage(message)
? () => _startEditMessage(message)
: null,
child: bubble,
);
@@ -1956,6 +2154,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_AttachButton(
hasText: _hasText,
onOpen: _openAttachmentSheet,
onLongOpen: _openAttachmentSheetScheduled,
uploadStatus: _uploadStatus,
mutedIcon: mutedIcon,
cs: cs,
@@ -2031,6 +2230,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(27),
onTap: hasText ? _sendMessage : null,
onLongPress: hasText ? _scheduleMessage : null,
depth: 8,
child: SizedBox(
width: 54,
@@ -2144,7 +2344,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _openAttachmentSheet() async {
Future<void> _openAttachmentSheetScheduled() async {
final when = await _pickScheduleTime();
if (when == null || !mounted) return;
await _openAttachmentSheet(scheduledTime: when.millisecondsSinceEpoch);
}
Future<void> _openAttachmentSheet({int? scheduledTime}) async {
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final hadKeyboard = keyboard > 0;
if (hadKeyboard) {
@@ -2154,8 +2360,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
await showAttachmentSheet(
context,
title: widget.name,
onSend: _sendPhotos,
onPickFile: _pickAndUploadFile,
onSend: scheduledTime == null
? _sendPhotos
: (picked, caption) =>
_sendScheduledPhotos(picked, caption, scheduledTime),
onPickFile: scheduledTime == null
? _pickAndUploadFile
: () => _pickAndUploadFile(scheduledTime: scheduledTime),
onShareLocation: _shareLocation,
onCreatePoll: _createPoll,
);
@@ -2167,12 +2378,15 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
if (_myId == 0) return;
final videos = picked.where((ph) => ph.item.isVideo).toList();
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.isEmpty) {
if (mounted)
showCustomNotification(context, 'Видео пока нельзя отправить');
return;
if (photos.isEmpty && videos.isEmpty) return;
for (var i = 0; i < videos.length; i++) {
final cap = (photos.isEmpty && i == 0) ? caption : '';
await _sendVideo(videos[i], cap);
}
if (photos.isEmpty) return;
final files = <File>[];
final attachments = <PhotoAttachment>[];
@@ -2268,6 +2482,185 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _sendVideo(
PickedPhoto video,
String caption, {
int? scheduledTime,
}) async {
if (_myId == 0) return;
final file =
video.editedFile ??
video.item.localFile ??
await video.item.originFile();
if (file == null || !mounted) return;
final scheduled = scheduledTime != null;
final durationMs = video.item.duration?.inMilliseconds;
String? tempId;
ValueNotifier<List<double>>? progress;
if (scheduled) {
showCustomNotification(context, 'Загрузка…');
} else {
tempId = _nextTempId();
progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: caption.isEmpty ? null : caption,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [VideoAttachment(duration: durationMs)],
),
);
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
}
final progressNotifier = progress;
try {
final info = await messagesModule.requestVideoUploadUrl();
if (info == null || info.url.isEmpty) throw Exception('no_url');
final ok = await fileUploader.uploadVideoFile(
Uri.parse(info.url),
file,
onProgress: progressNotifier == null
? null
: (sent, total) {
if (total > 0) {
progressNotifier.value = [(sent / total).clamp(0.0, 1.0)];
}
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendVideoMessage(
widget.chatId,
info.token,
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
if (serverMsg == null) throw Exception('send_failed');
if (scheduled) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
final real = CachedMessage.fromPushPayload(
_myId,
widget.chatId,
serverMsg,
);
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = real;
_bumpMessages();
unawaited(_persistOutgoing(real, removeId: tempId));
}
_disposePhotoProgress(tempId!);
}
} catch (_) {
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
if (scheduled) {
Haptics.error();
showCustomNotification(context, 'Не удалось запланировать видео');
} else {
_failPhotoMessage(tempId!);
}
}
}
Future<void> _sendScheduledPhotos(
List<PickedPhoto> picked,
String caption,
int scheduledTime,
) async {
if (_myId == 0) return;
final videos = picked.where((ph) => ph.item.isVideo).toList();
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.isEmpty && videos.isEmpty) return;
for (var i = 0; i < videos.length; i++) {
final cap = (photos.isEmpty && i == 0) ? caption : '';
await _sendVideo(videos[i], cap, scheduledTime: scheduledTime);
}
if (photos.isEmpty) return;
final files = <File>[];
for (final photo in photos) {
final edited = photo.editedFile;
final file =
edited ?? photo.item.localFile ?? await photo.item.originFile();
if (file != null) files.add(file);
}
if (files.isEmpty || !mounted) return;
showCustomNotification(context, 'Загрузка…');
final progress = ValueNotifier<List<double>>(
List<double>.filled(files.length, 0),
);
try {
final tokens = await Future.wait(
List.generate(
files.length,
(i) => _uploadOnePhoto(files[i], i, progress),
),
);
if (!mounted) return;
if (tokens.any((t) => t == null)) {
showCustomNotification(context, 'Не удалось загрузить фото');
return;
}
final result = await messagesModule.sendPhotoMessage(
widget.chatId,
tokens.cast<String>(),
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (!mounted) return;
if (result != null) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
showCustomNotification(context, 'Не удалось запланировать');
}
} catch (_) {
if (mounted) {
Haptics.error();
showCustomNotification(context, 'Ошибка при загрузке');
}
} finally {
progress.dispose();
}
}
Future<void> _sendAttachMessage(
List<MessageAttachment> optimistic,
Future<Map<String, dynamic>?> Function() send,
@@ -2416,7 +2809,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_photoUploadProgress.remove(tempId)?.dispose();
}
Future<void> _pickAndUploadFile() async {
Future<void> _pickAndUploadFile({int? scheduledTime}) async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
@@ -2425,9 +2818,12 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_showAttachmentPanel.value = false;
_uploadStatus.value = _UploadStatus(active: true, total: file.size);
final tempId = _addOptimisticFileMessage(
FileAttachment(name: file.name, size: file.size),
);
final scheduled = scheduledTime != null;
final tempId = scheduled
? null
: _addOptimisticFileMessage(
FileAttachment(name: file.name, size: file.size),
);
UploadNotificationService.start(file.name);
@@ -2445,6 +2841,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
file: File(file.path!),
filename: file.name,
totalSize: file.size,
scheduledTime: scheduledTime,
)
.listen(
(event) {
@@ -2485,37 +2882,48 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
sentAt: DateTime.now(),
),
);
_updateFileMessageStatus(
tempId,
'sent',
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
name: file.name,
size: file.size,
),
);
if (scheduled) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
_updateFileMessageStatus(
tempId!,
'sent',
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
name: file.name,
size: file.size,
),
);
}
case UploadError(:final message):
stopNotif();
showCustomNotification(context, 'Ошибка: $message');
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
}
},
onDone: () {
if (!mounted) return;
stopNotif();
final inFlight = _messages.firstWhere(
(m) => m.id == tempId,
orElse: () => CachedMessage(
id: '',
accountId: 0,
chatId: 0,
senderId: 0,
time: 0,
),
);
if (inFlight.id == tempId && inFlight.status == 'sending') {
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) {
final inFlight = _messages.firstWhere(
(m) => m.id == tempId,
orElse: () => CachedMessage(
id: '',
accountId: 0,
chatId: 0,
senderId: 0,
time: 0,
),
);
if (inFlight.id == tempId && inFlight.status == 'sending') {
_updateFileMessageStatus(tempId, 'error');
}
}
_uploadStatus.value = const _UploadStatus();
_uploadSub = null;
@@ -2524,7 +2932,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
if (!mounted) return;
stopNotif();
showCustomNotification(context, 'Ошибка: $e');
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
_uploadStatus.value = const _UploadStatus();
_uploadSub = null;
},
@@ -2535,6 +2943,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
class _AttachButton extends StatelessWidget {
final ValueNotifier<bool> hasText;
final VoidCallback onOpen;
final VoidCallback onLongOpen;
final ValueNotifier<_UploadStatus> uploadStatus;
final Color mutedIcon;
final ColorScheme cs;
@@ -2542,6 +2951,7 @@ class _AttachButton extends StatelessWidget {
const _AttachButton({
required this.hasText,
required this.onOpen,
required this.onLongOpen,
required this.uploadStatus,
required this.mutedIcon,
required this.cs,
@@ -2559,7 +2969,9 @@ class _AttachButton extends StatelessWidget {
: (status.active
? cs.onSurfaceVariant.withValues(alpha: 0.5)
: mutedIcon);
final onTap = (isText || status.active) ? null : onOpen;
final disabled = isText || status.active;
final onTap = disabled ? null : onOpen;
final onLongPress = disabled ? null : onLongOpen;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isText ? 0 : 36,
@@ -2571,6 +2983,7 @@ class _AttachButton extends StatelessWidget {
: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
@@ -2828,12 +3241,14 @@ class _LongPressBubble extends StatefulWidget {
final CachedMessage message;
final bool isMe;
final VoidCallback onDelete;
final VoidCallback? onEdit;
const _LongPressBubble({
required this.child,
required this.message,
required this.isMe,
required this.onDelete,
this.onEdit,
});
@override
@@ -2885,6 +3300,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
controller: controller,
style: AppMessageActionsStyle.current.value,
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
@@ -2917,6 +3333,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
style: MessageActionsStyle.list,
interaction: MessageActionsInteraction.click,
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
@@ -0,0 +1,418 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../models/attachment.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart';
class ScheduledMessagesScreen extends StatefulWidget {
final int chatId;
final int accountId;
final String chatName;
const ScheduledMessagesScreen({
super.key,
required this.chatId,
required this.accountId,
required this.chatName,
});
@override
State<ScheduledMessagesScreen> createState() =>
_ScheduledMessagesScreenState();
}
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
final List<CachedMessage> _messages = [];
StreamSubscription<Packet>? _pushSub;
bool _loading = true;
@override
void initState() {
super.initState();
_pushSub = api.pushStream
.where(
(p) =>
p.opcode == Opcode.notifMsgDelayed &&
p.payload is Map &&
p.payload['chatId'] == widget.chatId,
)
.listen((_) => _load());
_load();
}
@override
void dispose() {
_pushSub?.cancel();
super.dispose();
}
Future<void> _load() async {
final list = await messagesModule.fetchDelayedMessages(
widget.accountId,
widget.chatId,
);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(list);
_loading = false;
});
}
Future<DateTime?> _pickTime(DateTime initial) async {
final now = DateTime.now();
final base = initial.isAfter(now) ? initial : now.add(const Duration(hours: 1));
final date = await showDatePicker(
context: context,
initialDate: base,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null || !mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(base),
);
if (time == null) return null;
final result = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (!result.isAfter(DateTime.now())) {
if (mounted) showCustomNotification(context, 'Время должно быть в будущем');
return null;
}
return result;
}
Future<void> _edit(CachedMessage msg) async {
final controller = TextEditingController(text: msg.text ?? '');
var when = DateTime.fromMillisecondsSinceEpoch(
msg.delayedTimeToFire ?? msg.time,
);
final cs = Theme.of(context).colorScheme;
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheet) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Изменить',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 5,
style: TextStyle(color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Текст сообщения',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () async {
final picked = await _pickTime(when);
if (picked != null) setSheet(() => when = picked);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Symbols.schedule, size: 18, color: cs.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
formatDateTimeWords(when),
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
Icon(Symbols.edit, size: 16, color: cs.onSurfaceVariant),
],
),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(sheetContext).pop(true),
child: const Text('Сохранить'),
),
],
),
),
),
);
if (saved != true || !mounted) {
controller.dispose();
return;
}
final ok = await messagesModule.editScheduledMessage(
widget.chatId,
msg.id,
text: controller.text.trim(),
timeToFire: when.millisecondsSinceEpoch,
);
controller.dispose();
if (!mounted) return;
if (ok) {
Haptics.send();
_load();
} else {
showCustomNotification(context, 'Не удалось изменить сообщение');
}
}
Future<void> _delete(CachedMessage msg) async {
final confirmed = await showConfirmDialog(
context,
title: 'Удалить запланированное сообщение?',
message: 'Сообщение не будет отправлено.',
confirmLabel: 'Удалить',
destructive: true,
);
if (!confirmed || !mounted) return;
final ok = await messagesModule.deleteMessages(
widget.chatId,
[msg.id],
forEveryone: true,
itemType: 'DELAYED',
);
if (!mounted) return;
if (ok) {
Haptics.send();
setState(() => _messages.removeWhere((m) => m.id == msg.id));
} else {
showCustomNotification(context, 'Не удалось удалить сообщение');
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Отложенные',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
Text(
widget.chatName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: cs.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _messages.isEmpty
? _empty(cs)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _tile(cs, _messages[i]),
),
),
);
}
Widget _empty(ColorScheme cs) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.schedule, size: 56, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Нет отложенных сообщений',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
],
),
);
(IconData, String)? _attachLabel(CachedMessage msg) {
final attaches = msg.attachments;
if (attaches == null || attaches.isEmpty) return null;
switch (attaches.first.type) {
case AttachmentType.photo:
return (Symbols.image, 'Фото');
case AttachmentType.video:
return (Symbols.videocam, 'Видео');
case AttachmentType.audio:
return (Symbols.mic, 'Голосовое');
case AttachmentType.file:
return (Symbols.description, 'Файл');
case AttachmentType.location:
return (Symbols.location_on, 'Геопозиция');
default:
return (Symbols.attach_file, 'Вложение');
}
}
Widget _tile(ColorScheme cs, CachedMessage msg) {
final fireMs = msg.delayedTimeToFire ?? msg.time;
final fireAt = DateTime.fromMillisecondsSinceEpoch(fireMs);
final hasText = (msg.text ?? '').isNotEmpty;
final attach = _attachLabel(msg);
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => _edit(msg),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (attach != null)
Padding(
padding: EdgeInsets.only(bottom: hasText ? 4 : 0),
child: Row(
children: [
Icon(attach.$1, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
Text(
attach.$2,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (hasText)
Text(
msg.text!,
style: TextStyle(color: cs.onSurface, fontSize: 15),
)
else if (attach == null)
Text(
'Вложение',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Symbols.schedule,
size: 14,
color: cs.primary,
weight: 500,
),
const SizedBox(width: 4),
Text(
formatDateTimeWords(fireAt),
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
const SizedBox(width: 4),
IconButton(
icon: Icon(Symbols.edit, color: cs.onSurfaceVariant, weight: 400),
onPressed: () => _edit(msg),
),
IconButton(
icon: Icon(Symbols.delete, color: cs.error, weight: 400),
onPressed: () => _delete(msg),
),
],
),
),
);
}
}
+11 -3
View File
@@ -86,6 +86,7 @@ class GlossyPill extends StatelessWidget {
final BorderRadius borderRadius;
final Color? color;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final double depth;
final bool elevated;
final BorderSide? borderSide;
@@ -97,6 +98,7 @@ class GlossyPill extends StatelessWidget {
BorderRadius? borderRadius,
this.color,
this.onTap,
this.onLongPress,
this.depth = 10,
this.elevated = false,
this.borderSide,
@@ -131,7 +133,9 @@ class GlossyPill extends StatelessWidget {
side: borderSide ?? BorderSide.none,
),
clipBehavior: Clip.antiAlias,
child: onTap == null ? content : InkWell(onTap: onTap, child: content),
child: onTap == null && onLongPress == null
? content
: InkWell(onTap: onTap, onLongPress: onLongPress, child: content),
);
}
@@ -173,12 +177,16 @@ class GlossyPill extends StatelessWidget {
),
),
],
if (onTap == null)
if (onTap == null && onLongPress == null)
content
else
Material(
type: MaterialType.transparency,
child: InkWell(onTap: onTap, child: content),
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: content,
),
),
],
),
@@ -74,6 +74,7 @@ void showMessageActions({
required MessageActionsStyle style,
required VoidCallback onDispose,
VoidCallback? onDelete,
VoidCallback? onEdit,
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
@@ -89,6 +90,7 @@ void showMessageActions({
style: style,
interaction: interaction,
onDelete: onDelete,
onEdit: onEdit,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
@@ -109,6 +111,7 @@ class _MessageActionsLayer extends StatefulWidget {
final MessageActionsInteraction interaction;
final VoidCallback onDismiss;
final VoidCallback? onDelete;
final VoidCallback? onEdit;
const _MessageActionsLayer({
required this.snapshot,
@@ -121,6 +124,7 @@ class _MessageActionsLayer extends StatefulWidget {
required this.interaction,
required this.onDismiss,
this.onDelete,
this.onEdit,
});
@override
@@ -285,6 +289,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
final hasText = widget.messageText != null && widget.messageText!.isNotEmpty;
return <_Action>[
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
if (widget.isMe && widget.onEdit != null)
_Action(Symbols.edit, 'Изменить', _edit),
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
_Action(
@@ -356,6 +362,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
onDelete?.call();
}
Future<void> _edit() async {
final onEdit = widget.onEdit;
await _close();
onEdit?.call();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');