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

This commit is contained in:
klockky
2026-06-13 08:02:12 +00:00
parent d6acfa52fa
commit c8a64e2871
6 changed files with 1262 additions and 58 deletions
+453 -36
View File
@@ -41,6 +41,7 @@ import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart';
import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/swipe_to_pop.dart';
import 'scheduled_messages_screen.dart';
class _UploadStatus {
final bool active;
@@ -623,6 +624,116 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_messagesRev.value++;
}
bool _canEditMessage(CachedMessage message) {
if (message.senderId != _myId) return false;
if (message.id.startsWith('temp_')) return false;
if (message.isControl) return false;
final status = message.status;
if (status == 'sending' || status == 'error') return false;
return true;
}
Future<void> _startEditMessage(CachedMessage message) async {
final cs = Theme.of(context).colorScheme;
final controller = TextEditingController(text: message.text ?? '');
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Изменить сообщение',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 6,
style: TextStyle(color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Текст сообщения',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(sheetContext).pop(true),
child: const Text('Сохранить'),
),
],
),
),
);
if (saved != true || !mounted) {
controller.dispose();
return;
}
final newText = controller.text.trim();
controller.dispose();
if (newText == (message.text ?? '')) return;
final ok = await messagesModule.editMessage(
widget.chatId,
message.id,
text: newText,
);
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось изменить сообщение');
return;
}
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx != -1) {
final old = _messages[idx];
final edited = CachedMessage(
id: old.id,
accountId: old.accountId,
chatId: old.chatId,
senderId: old.senderId,
text: newText.isEmpty ? null : newText,
time: old.time,
status: 'EDITED',
payload: old.payload,
attachments: old.attachments,
isControl: old.isControl,
);
_messages[idx] = edited;
_bumpMessages();
unawaited(_persistOutgoing(edited));
}
Haptics.send();
}
Future<void> _confirmDeleteMessage(CachedMessage message, bool isMe) async {
final isLocalOnly = message.id.startsWith('temp_');
final canForEveryone = isMe && !isLocalOnly;
@@ -894,6 +1005,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
],
),
actions: [
IconButton(
icon: const Icon(Symbols.schedule, weight: 400),
onPressed: _openScheduledMessages,
),
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: _startCall,
@@ -1094,6 +1209,78 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _scheduleMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
final when = await _pickScheduleTime();
if (when == null || !mounted) return;
try {
await messagesModule.sendMessage(
_myId,
widget.chatId,
text,
scheduledTime: when.millisecondsSinceEpoch,
);
if (!mounted) return;
_hasText.value = false;
_messageController.clear();
Haptics.send();
showCustomNotification(
context,
'Запланировано на ${formatDateTimeWords(when)}',
);
} catch (_) {
if (!mounted) return;
Haptics.error();
showCustomNotification(context, 'Не удалось запланировать сообщение');
}
}
Future<DateTime?> _pickScheduleTime() async {
final now = DateTime.now();
final suggested = now.add(const Duration(hours: 1));
final date = await showDatePicker(
context: context,
initialDate: suggested,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null || !mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(suggested),
);
if (time == null) return null;
final result = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (!result.isAfter(DateTime.now())) {
if (mounted) {
showCustomNotification(context, 'Время должно быть в будущем');
}
return null;
}
return result;
}
void _openScheduledMessages() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ScheduledMessagesScreen(
chatId: widget.chatId,
accountId: _myId,
chatName: widget.name,
),
),
);
}
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
try {
if (removeId != null && removeId != msg.id) {
@@ -1514,6 +1701,14 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Symbols.schedule,
weight: 500,
color: cs.onSurface,
),
onPressed: _openScheduledMessages,
),
IconButton(
icon: Icon(
Symbols.call,
@@ -1658,6 +1853,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
message: message,
isMe: isMe,
onDelete: () => _confirmDeleteMessage(message, isMe),
onEdit: _canEditMessage(message)
? () => _startEditMessage(message)
: null,
child: bubble,
);
@@ -1956,6 +2154,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_AttachButton(
hasText: _hasText,
onOpen: _openAttachmentSheet,
onLongOpen: _openAttachmentSheetScheduled,
uploadStatus: _uploadStatus,
mutedIcon: mutedIcon,
cs: cs,
@@ -2031,6 +2230,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(27),
onTap: hasText ? _sendMessage : null,
onLongPress: hasText ? _scheduleMessage : null,
depth: 8,
child: SizedBox(
width: 54,
@@ -2144,7 +2344,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _openAttachmentSheet() async {
Future<void> _openAttachmentSheetScheduled() async {
final when = await _pickScheduleTime();
if (when == null || !mounted) return;
await _openAttachmentSheet(scheduledTime: when.millisecondsSinceEpoch);
}
Future<void> _openAttachmentSheet({int? scheduledTime}) async {
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final hadKeyboard = keyboard > 0;
if (hadKeyboard) {
@@ -2154,8 +2360,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
await showAttachmentSheet(
context,
title: widget.name,
onSend: _sendPhotos,
onPickFile: _pickAndUploadFile,
onSend: scheduledTime == null
? _sendPhotos
: (picked, caption) =>
_sendScheduledPhotos(picked, caption, scheduledTime),
onPickFile: scheduledTime == null
? _pickAndUploadFile
: () => _pickAndUploadFile(scheduledTime: scheduledTime),
onShareLocation: _shareLocation,
onCreatePoll: _createPoll,
);
@@ -2167,12 +2378,15 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
if (_myId == 0) return;
final videos = picked.where((ph) => ph.item.isVideo).toList();
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.isEmpty) {
if (mounted)
showCustomNotification(context, 'Видео пока нельзя отправить');
return;
if (photos.isEmpty && videos.isEmpty) return;
for (var i = 0; i < videos.length; i++) {
final cap = (photos.isEmpty && i == 0) ? caption : '';
await _sendVideo(videos[i], cap);
}
if (photos.isEmpty) return;
final files = <File>[];
final attachments = <PhotoAttachment>[];
@@ -2268,6 +2482,185 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
}
}
Future<void> _sendVideo(
PickedPhoto video,
String caption, {
int? scheduledTime,
}) async {
if (_myId == 0) return;
final file =
video.editedFile ??
video.item.localFile ??
await video.item.originFile();
if (file == null || !mounted) return;
final scheduled = scheduledTime != null;
final durationMs = video.item.duration?.inMilliseconds;
String? tempId;
ValueNotifier<List<double>>? progress;
if (scheduled) {
showCustomNotification(context, 'Загрузка…');
} else {
tempId = _nextTempId();
progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: caption.isEmpty ? null : caption,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [VideoAttachment(duration: durationMs)],
),
);
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
}
final progressNotifier = progress;
try {
final info = await messagesModule.requestVideoUploadUrl();
if (info == null || info.url.isEmpty) throw Exception('no_url');
final ok = await fileUploader.uploadVideoFile(
Uri.parse(info.url),
file,
onProgress: progressNotifier == null
? null
: (sent, total) {
if (total > 0) {
progressNotifier.value = [(sent / total).clamp(0.0, 1.0)];
}
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendVideoMessage(
widget.chatId,
info.token,
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
if (serverMsg == null) throw Exception('send_failed');
if (scheduled) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
final real = CachedMessage.fromPushPayload(
_myId,
widget.chatId,
serverMsg,
);
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = real;
_bumpMessages();
unawaited(_persistOutgoing(real, removeId: tempId));
}
_disposePhotoProgress(tempId!);
}
} catch (_) {
if (!mounted) {
if (tempId != null) _disposePhotoProgress(tempId);
return;
}
if (scheduled) {
Haptics.error();
showCustomNotification(context, 'Не удалось запланировать видео');
} else {
_failPhotoMessage(tempId!);
}
}
}
Future<void> _sendScheduledPhotos(
List<PickedPhoto> picked,
String caption,
int scheduledTime,
) async {
if (_myId == 0) return;
final videos = picked.where((ph) => ph.item.isVideo).toList();
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.isEmpty && videos.isEmpty) return;
for (var i = 0; i < videos.length; i++) {
final cap = (photos.isEmpty && i == 0) ? caption : '';
await _sendVideo(videos[i], cap, scheduledTime: scheduledTime);
}
if (photos.isEmpty) return;
final files = <File>[];
for (final photo in photos) {
final edited = photo.editedFile;
final file =
edited ?? photo.item.localFile ?? await photo.item.originFile();
if (file != null) files.add(file);
}
if (files.isEmpty || !mounted) return;
showCustomNotification(context, 'Загрузка…');
final progress = ValueNotifier<List<double>>(
List<double>.filled(files.length, 0),
);
try {
final tokens = await Future.wait(
List.generate(
files.length,
(i) => _uploadOnePhoto(files[i], i, progress),
),
);
if (!mounted) return;
if (tokens.any((t) => t == null)) {
showCustomNotification(context, 'Не удалось загрузить фото');
return;
}
final result = await messagesModule.sendPhotoMessage(
widget.chatId,
tokens.cast<String>(),
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
if (!mounted) return;
if (result != null) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
showCustomNotification(context, 'Не удалось запланировать');
}
} catch (_) {
if (mounted) {
Haptics.error();
showCustomNotification(context, 'Ошибка при загрузке');
}
} finally {
progress.dispose();
}
}
Future<void> _sendAttachMessage(
List<MessageAttachment> optimistic,
Future<Map<String, dynamic>?> Function() send,
@@ -2416,7 +2809,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_photoUploadProgress.remove(tempId)?.dispose();
}
Future<void> _pickAndUploadFile() async {
Future<void> _pickAndUploadFile({int? scheduledTime}) async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
@@ -2425,9 +2818,12 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_showAttachmentPanel.value = false;
_uploadStatus.value = _UploadStatus(active: true, total: file.size);
final tempId = _addOptimisticFileMessage(
FileAttachment(name: file.name, size: file.size),
);
final scheduled = scheduledTime != null;
final tempId = scheduled
? null
: _addOptimisticFileMessage(
FileAttachment(name: file.name, size: file.size),
);
UploadNotificationService.start(file.name);
@@ -2445,6 +2841,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
file: File(file.path!),
filename: file.name,
totalSize: file.size,
scheduledTime: scheduledTime,
)
.listen(
(event) {
@@ -2485,37 +2882,48 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
sentAt: DateTime.now(),
),
);
_updateFileMessageStatus(
tempId,
'sent',
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
name: file.name,
size: file.size,
),
);
if (scheduled) {
Haptics.send();
showCustomNotification(
context,
'Запланировано на '
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
);
} else {
_updateFileMessageStatus(
tempId!,
'sent',
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
name: file.name,
size: file.size,
),
);
}
case UploadError(:final message):
stopNotif();
showCustomNotification(context, 'Ошибка: $message');
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
}
},
onDone: () {
if (!mounted) return;
stopNotif();
final inFlight = _messages.firstWhere(
(m) => m.id == tempId,
orElse: () => CachedMessage(
id: '',
accountId: 0,
chatId: 0,
senderId: 0,
time: 0,
),
);
if (inFlight.id == tempId && inFlight.status == 'sending') {
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) {
final inFlight = _messages.firstWhere(
(m) => m.id == tempId,
orElse: () => CachedMessage(
id: '',
accountId: 0,
chatId: 0,
senderId: 0,
time: 0,
),
);
if (inFlight.id == tempId && inFlight.status == 'sending') {
_updateFileMessageStatus(tempId, 'error');
}
}
_uploadStatus.value = const _UploadStatus();
_uploadSub = null;
@@ -2524,7 +2932,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
if (!mounted) return;
stopNotif();
showCustomNotification(context, 'Ошибка: $e');
_updateFileMessageStatus(tempId, 'error');
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
_uploadStatus.value = const _UploadStatus();
_uploadSub = null;
},
@@ -2535,6 +2943,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
class _AttachButton extends StatelessWidget {
final ValueNotifier<bool> hasText;
final VoidCallback onOpen;
final VoidCallback onLongOpen;
final ValueNotifier<_UploadStatus> uploadStatus;
final Color mutedIcon;
final ColorScheme cs;
@@ -2542,6 +2951,7 @@ class _AttachButton extends StatelessWidget {
const _AttachButton({
required this.hasText,
required this.onOpen,
required this.onLongOpen,
required this.uploadStatus,
required this.mutedIcon,
required this.cs,
@@ -2559,7 +2969,9 @@ class _AttachButton extends StatelessWidget {
: (status.active
? cs.onSurfaceVariant.withValues(alpha: 0.5)
: mutedIcon);
final onTap = (isText || status.active) ? null : onOpen;
final disabled = isText || status.active;
final onTap = disabled ? null : onOpen;
final onLongPress = disabled ? null : onLongOpen;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isText ? 0 : 36,
@@ -2571,6 +2983,7 @@ class _AttachButton extends StatelessWidget {
: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
@@ -2828,12 +3241,14 @@ class _LongPressBubble extends StatefulWidget {
final CachedMessage message;
final bool isMe;
final VoidCallback onDelete;
final VoidCallback? onEdit;
const _LongPressBubble({
required this.child,
required this.message,
required this.isMe,
required this.onDelete,
this.onEdit,
});
@override
@@ -2885,6 +3300,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
controller: controller,
style: AppMessageActionsStyle.current.value,
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
@@ -2917,6 +3333,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
style: MessageActionsStyle.list,
interaction: MessageActionsInteraction.click,
onDelete: widget.onDelete,
onEdit: widget.onEdit,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;