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

This commit is contained in:
klockky
2026-06-13 08:02:12 +00:00
parent b9a89a4437
commit efa50375e0
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;
@@ -0,0 +1,418 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/protocol/opcode_map.dart';
import '../../../models/attachment.dart';
import '../../../core/protocol/packet.dart';
import '../../../core/utils/format.dart';
import '../../../core/utils/haptics.dart';
import '../../../main.dart';
import '../../widgets/confirm_dialog.dart';
import '../../widgets/custom_notification.dart';
class ScheduledMessagesScreen extends StatefulWidget {
final int chatId;
final int accountId;
final String chatName;
const ScheduledMessagesScreen({
super.key,
required this.chatId,
required this.accountId,
required this.chatName,
});
@override
State<ScheduledMessagesScreen> createState() =>
_ScheduledMessagesScreenState();
}
class _ScheduledMessagesScreenState extends State<ScheduledMessagesScreen> {
final List<CachedMessage> _messages = [];
StreamSubscription<Packet>? _pushSub;
bool _loading = true;
@override
void initState() {
super.initState();
_pushSub = api.pushStream
.where(
(p) =>
p.opcode == Opcode.notifMsgDelayed &&
p.payload is Map &&
p.payload['chatId'] == widget.chatId,
)
.listen((_) => _load());
_load();
}
@override
void dispose() {
_pushSub?.cancel();
super.dispose();
}
Future<void> _load() async {
final list = await messagesModule.fetchDelayedMessages(
widget.accountId,
widget.chatId,
);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(list);
_loading = false;
});
}
Future<DateTime?> _pickTime(DateTime initial) async {
final now = DateTime.now();
final base = initial.isAfter(now) ? initial : now.add(const Duration(hours: 1));
final date = await showDatePicker(
context: context,
initialDate: base,
firstDate: now,
lastDate: now.add(const Duration(days: 365)),
);
if (date == null || !mounted) return null;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(base),
);
if (time == null) return null;
final result = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (!result.isAfter(DateTime.now())) {
if (mounted) showCustomNotification(context, 'Время должно быть в будущем');
return null;
}
return result;
}
Future<void> _edit(CachedMessage msg) async {
final controller = TextEditingController(text: msg.text ?? '');
var when = DateTime.fromMillisecondsSinceEpoch(
msg.delayedTimeToFire ?? msg.time,
);
final cs = Theme.of(context).colorScheme;
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheet) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Изменить',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 5,
style: TextStyle(color: cs.onSurface),
decoration: InputDecoration(
hintText: 'Текст сообщения',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: 12),
InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () async {
final picked = await _pickTime(when);
if (picked != null) setSheet(() => when = picked);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Symbols.schedule, size: 18, color: cs.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
formatDateTimeWords(when),
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
Icon(Symbols.edit, size: 16, color: cs.onSurfaceVariant),
],
),
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(sheetContext).pop(true),
child: const Text('Сохранить'),
),
],
),
),
),
);
if (saved != true || !mounted) {
controller.dispose();
return;
}
final ok = await messagesModule.editScheduledMessage(
widget.chatId,
msg.id,
text: controller.text.trim(),
timeToFire: when.millisecondsSinceEpoch,
);
controller.dispose();
if (!mounted) return;
if (ok) {
Haptics.send();
_load();
} else {
showCustomNotification(context, 'Не удалось изменить сообщение');
}
}
Future<void> _delete(CachedMessage msg) async {
final confirmed = await showConfirmDialog(
context,
title: 'Удалить запланированное сообщение?',
message: 'Сообщение не будет отправлено.',
confirmLabel: 'Удалить',
destructive: true,
);
if (!confirmed || !mounted) return;
final ok = await messagesModule.deleteMessages(
widget.chatId,
[msg.id],
forEveryone: true,
itemType: 'DELAYED',
);
if (!mounted) return;
if (ok) {
Haptics.send();
setState(() => _messages.removeWhere((m) => m.id == msg.id));
} else {
showCustomNotification(context, 'Не удалось удалить сообщение');
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Отложенные',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
Text(
widget.chatName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: cs.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _messages.isEmpty
? _empty(cs)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _tile(cs, _messages[i]),
),
),
);
}
Widget _empty(ColorScheme cs) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.schedule, size: 56, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'Нет отложенных сообщений',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
],
),
);
(IconData, String)? _attachLabel(CachedMessage msg) {
final attaches = msg.attachments;
if (attaches == null || attaches.isEmpty) return null;
switch (attaches.first.type) {
case AttachmentType.photo:
return (Symbols.image, 'Фото');
case AttachmentType.video:
return (Symbols.videocam, 'Видео');
case AttachmentType.audio:
return (Symbols.mic, 'Голосовое');
case AttachmentType.file:
return (Symbols.description, 'Файл');
case AttachmentType.location:
return (Symbols.location_on, 'Геопозиция');
default:
return (Symbols.attach_file, 'Вложение');
}
}
Widget _tile(ColorScheme cs, CachedMessage msg) {
final fireMs = msg.delayedTimeToFire ?? msg.time;
final fireAt = DateTime.fromMillisecondsSinceEpoch(fireMs);
final hasText = (msg.text ?? '').isNotEmpty;
final attach = _attachLabel(msg);
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => _edit(msg),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (attach != null)
Padding(
padding: EdgeInsets.only(bottom: hasText ? 4 : 0),
child: Row(
children: [
Icon(attach.$1, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
Text(
attach.$2,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (hasText)
Text(
msg.text!,
style: TextStyle(color: cs.onSurface, fontSize: 15),
)
else if (attach == null)
Text(
'Вложение',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Symbols.schedule,
size: 14,
color: cs.primary,
weight: 500,
),
const SizedBox(width: 4),
Text(
formatDateTimeWords(fireAt),
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
),
const SizedBox(width: 4),
IconButton(
icon: Icon(Symbols.edit, color: cs.onSurfaceVariant, weight: 400),
onPressed: () => _edit(msg),
),
IconButton(
icon: Icon(Symbols.delete, color: cs.error, weight: 400),
onPressed: () => _delete(msg),
),
],
),
),
);
}
}
+11 -3
View File
@@ -86,6 +86,7 @@ class GlossyPill extends StatelessWidget {
final BorderRadius borderRadius;
final Color? color;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final double depth;
final bool elevated;
final BorderSide? borderSide;
@@ -97,6 +98,7 @@ class GlossyPill extends StatelessWidget {
BorderRadius? borderRadius,
this.color,
this.onTap,
this.onLongPress,
this.depth = 10,
this.elevated = false,
this.borderSide,
@@ -131,7 +133,9 @@ class GlossyPill extends StatelessWidget {
side: borderSide ?? BorderSide.none,
),
clipBehavior: Clip.antiAlias,
child: onTap == null ? content : InkWell(onTap: onTap, child: content),
child: onTap == null && onLongPress == null
? content
: InkWell(onTap: onTap, onLongPress: onLongPress, child: content),
);
}
@@ -173,12 +177,16 @@ class GlossyPill extends StatelessWidget {
),
),
],
if (onTap == null)
if (onTap == null && onLongPress == null)
content
else
Material(
type: MaterialType.transparency,
child: InkWell(onTap: onTap, child: content),
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: content,
),
),
],
),
@@ -74,6 +74,7 @@ void showMessageActions({
required MessageActionsStyle style,
required VoidCallback onDispose,
VoidCallback? onDelete,
VoidCallback? onEdit,
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
@@ -89,6 +90,7 @@ void showMessageActions({
style: style,
interaction: interaction,
onDelete: onDelete,
onEdit: onEdit,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
@@ -109,6 +111,7 @@ class _MessageActionsLayer extends StatefulWidget {
final MessageActionsInteraction interaction;
final VoidCallback onDismiss;
final VoidCallback? onDelete;
final VoidCallback? onEdit;
const _MessageActionsLayer({
required this.snapshot,
@@ -121,6 +124,7 @@ class _MessageActionsLayer extends StatefulWidget {
required this.interaction,
required this.onDismiss,
this.onDelete,
this.onEdit,
});
@override
@@ -285,6 +289,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
final hasText = widget.messageText != null && widget.messageText!.isNotEmpty;
return <_Action>[
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
if (widget.isMe && widget.onEdit != null)
_Action(Symbols.edit, 'Изменить', _edit),
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
_Action(
@@ -356,6 +362,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
onDelete?.call();
}
Future<void> _edit() async {
final onEdit = widget.onEdit;
await _close();
onEdit?.call();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');