From c8a64e2871c611266b6abff42fbcf11a92aa5fce Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 13 Jun 2026 08:02:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/file_uploader.dart | 131 +++++ lib/backend/modules/messages.dart | 256 ++++++++- lib/frontend/screens/chats/chat_screen.dart | 489 ++++++++++++++++-- .../chats/scheduled_messages_screen.dart | 418 +++++++++++++++ lib/frontend/widgets/glossy_pill.dart | 14 +- .../widgets/message_actions_overlay.dart | 12 + 6 files changed, 1262 insertions(+), 58 deletions(-) create mode 100644 lib/frontend/screens/chats/scheduled_messages_screen.dart diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 26007cc..a324736 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -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 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 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 _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() diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 12ced01..d32483b 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -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 _decodeRows(List> rows) => rows.map(CachedMessage.fromDbRow).toList(); @@ -435,15 +459,23 @@ class MessagesModule { int chatId, String text, { bool notify = true, + int? scheduledTime, }) async { + final message = { + '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> 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 = []; + for (final m in messagesData) { + if (m is! Map) continue; + final msg = _parseMessage(m.cast(), 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 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': [], + 'text': text, + }; + + final response = await _api.sendRequest(Opcode.msgEdit, payload); + return response.isOk; + } + + /// Редактирует отложенное сообщение: меняет текст и/или время отправки. + /// + /// Вложения сервер сохраняет сам — в payload они не передаются. + Future 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': [], + 'text': text, + 'delayedAttributes': { + 'timeToFire': timeToFire, + 'notifySender': true, + }, + }; + + final response = await _api.sendRequest(Opcode.msgEdit, payload); + return response.isOk; + } + Future deleteMessages( int chatId, List 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 = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + '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': [], - '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 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.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 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?> sendVideoMessage( + int chatId, + String token, { + String? caption, + bool notify = true, + int? scheduledTime, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + '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++) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a4e31de..82c31fb 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 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 _startEditMessage(CachedMessage message) async { + final cs = Theme.of(context).colorScheme; + final controller = TextEditingController(text: message.text ?? ''); + + final saved = await showModalBottomSheet( + 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 _confirmDeleteMessage(CachedMessage message, bool isMe) async { final isLocalOnly = message.id.startsWith('temp_'); final canForEveryone = isMe && !isLocalOnly; @@ -894,6 +1005,10 @@ class _ChatScreenState extends State 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 with TickerProviderStateMixin { } } + Future _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 _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 _persistOutgoing(CachedMessage msg, {String? removeId}) async { try { if (removeId != null && removeId != msg.id) { @@ -1514,6 +1701,14 @@ class _ChatScreenState extends State 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 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 with TickerProviderStateMixin { _AttachButton( hasText: _hasText, onOpen: _openAttachmentSheet, + onLongOpen: _openAttachmentSheetScheduled, uploadStatus: _uploadStatus, mutedIcon: mutedIcon, cs: cs, @@ -2031,6 +2230,7 @@ class _ChatScreenState extends State 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 with TickerProviderStateMixin { } } - Future _openAttachmentSheet() async { + Future _openAttachmentSheetScheduled() async { + final when = await _pickScheduleTime(); + if (when == null || !mounted) return; + await _openAttachmentSheet(scheduledTime: when.millisecondsSinceEpoch); + } + + Future _openAttachmentSheet({int? scheduledTime}) async { final keyboard = MediaQuery.viewInsetsOf(context).bottom; final hadKeyboard = keyboard > 0; if (hadKeyboard) { @@ -2154,8 +2360,13 @@ class _ChatScreenState extends State 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 with TickerProviderStateMixin { Future _sendPhotos(List 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 = []; final attachments = []; @@ -2268,6 +2482,185 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } } + Future _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>? progress; + if (scheduled) { + showCustomNotification(context, 'Загрузка…'); + } else { + tempId = _nextTempId(); + progress = ValueNotifier>(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 _sendScheduledPhotos( + List 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 = []; + 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.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(), + 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 _sendAttachMessage( List optimistic, Future?> Function() send, @@ -2416,7 +2809,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _photoUploadProgress.remove(tempId)?.dispose(); } - Future _pickAndUploadFile() async { + Future _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 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 with TickerProviderStateMixin { file: File(file.path!), filename: file.name, totalSize: file.size, + scheduledTime: scheduledTime, ) .listen( (event) { @@ -2485,37 +2882,48 @@ class _ChatScreenState extends State 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 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 with TickerProviderStateMixin { class _AttachButton extends StatelessWidget { final ValueNotifier 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; diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart new file mode 100644 index 0000000..a557e8a --- /dev/null +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -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 createState() => + _ScheduledMessagesScreenState(); +} + +class _ScheduledMessagesScreenState extends State { + final List _messages = []; + StreamSubscription? _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 _load() async { + final list = await messagesModule.fetchDelayedMessages( + widget.accountId, + widget.chatId, + ); + if (!mounted) return; + setState(() { + _messages + ..clear() + ..addAll(list); + _loading = false; + }); + } + + Future _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 _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( + 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 _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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index f5cf94c..2d00d16 100644 --- a/lib/frontend/widgets/glossy_pill.dart +++ b/lib/frontend/widgets/glossy_pill.dart @@ -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, + ), ), ], ), diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index d22a7c0..7bef9ec 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -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 _edit() async { + final onEdit = widget.onEdit; + await _close(); + onEdit?.call(); + } + Future _stub(String name) async { if (!mounted) return; showCustomNotification(context, '$name — пока в разработке');