feat(chat): отложенные сообщения и отправка видео
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 — пока в разработке');
|
||||
|
||||
Reference in New Issue
Block a user