This commit is contained in:
Jganenok
2026-05-15 18:58:58 +07:00
parent f093e7c802
commit 0431db1c77
5 changed files with 1115 additions and 379 deletions
+17 -3
View File
@@ -278,7 +278,6 @@ class MessagesModule {
// Detect CONTROL
if (attachments.any((a) => a.type == AttachmentType.control)) {
isControl = true;
debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}');
}
}
}
@@ -304,7 +303,7 @@ class MessagesModule {
return int.tryParse(value.toString()) ?? 0;
}
Future<void> sendMessage(
Future<String> sendMessage(
int accountId,
int chatId,
String text, {
@@ -321,7 +320,22 @@ class MessagesModule {
'notify': notify,
};
await _api.sendRequest(Opcode.msgSend, payload);
final response = await _api.sendRequest(Opcode.msgSend, payload);
if (!response.isOk) {
final msg = (response.payload is Map)
? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки')
: 'Ошибка отправки';
throw Exception(msg.toString());
}
final data = response.payload;
if (data is Map) {
final msgMap = data['message'];
if (msgMap is Map) {
final id = msgMap['id'];
if (id != null) return id.toString();
}
}
return '';
}
Future<TranscriptionResult> requestTranscription(
File diff suppressed because it is too large Load Diff
@@ -787,7 +787,7 @@ class _ChatListScreenState extends State<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 2),
padding: const EdgeInsets.fromLTRB(20, 6, 20, 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -933,7 +933,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
child: Container(
height: 44,
decoration: BoxDecoration(
+139 -39
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -51,7 +52,6 @@ class _ChatScreenState extends State<ChatScreen>
final GlobalKey _listKey = GlobalKey();
bool _hasText = false;
bool _isLoading = true;
bool _isSending = false;
bool _showAttachmentPanel = false;
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
@@ -64,6 +64,7 @@ class _ChatScreenState extends State<ChatScreen>
late final AnimationController _floatingDateAnimController;
final Map<int, GlobalKey> _separatorKeys = {};
double _lastScrollOffset = 0;
String? _lastSentId;
@override
void initState() {
@@ -87,10 +88,10 @@ class _ChatScreenState extends State<ChatScreen>
final activeProfile = await AppDatabase.loadActiveProfile();
_myId = activeProfile?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
chat = value[0];
}).catchError((error) {
});
if (mounted && value.isNotEmpty) {
setState(() { chat = value.first; });
}
}).catchError((_) {});
final cachedRows = await AppDatabase.loadMessages(
_myId,
@@ -155,17 +156,29 @@ class _ChatScreenState extends State<ChatScreen>
}
}
String? _effectiveStatus(CachedMessage msg) {
if (msg.senderId != _myId) return null;
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
final c = chat;
if (c == null) return 'sent';
int otherReadTime = 0;
for (final entry in c.participants.entries) {
if (entry.key != _myId && entry.value > otherReadTime) {
otherReadTime = entry.value;
}
}
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
return 'sent';
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty || _myId == 0) return;
setState(() {
_isSending = true;
});
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final now = DateTime.now().millisecondsSinceEpoch;
try {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final now = DateTime.now().millisecondsSinceEpoch;
final tempMessage = CachedMessage(
id: tempId,
@@ -178,6 +191,7 @@ class _ChatScreenState extends State<ChatScreen>
);
setState(() {
_lastSentId = tempId;
_messages.add(tempMessage);
_messageController.clear();
_hasText = false;
@@ -189,13 +203,13 @@ class _ChatScreenState extends State<ChatScreen>
_scrollToBottom();
await messagesModule.sendMessage(_myId, widget.chatId, text);
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1) {
if (index != -1 && mounted) {
setState(() {
_messages[index] = CachedMessage(
id: tempId,
id: actualId.isNotEmpty ? actualId : tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
@@ -206,12 +220,21 @@ class _ChatScreenState extends State<ChatScreen>
});
}
} catch (e) {
debugPrint('Error sending message: $e');
Haptics.error();
} finally {
setState(() {
_isSending = false;
});
final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) {
setState(() {
_messages[index] = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
});
}
}
}
@@ -429,7 +452,7 @@ class _ChatScreenState extends State<ChatScreen>
// TODO: Локализация
// TODO: Cклонения
String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently";
final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently";
return Scaffold(
backgroundColor: cs.surface,
appBar: PreferredSize(
@@ -505,7 +528,7 @@ class _ChatScreenState extends State<ChatScreen>
],
),
Text(
status ?? "",
status,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
@@ -590,8 +613,6 @@ class _ChatScreenState extends State<ChatScreen>
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;
debugPrint(
'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}');
final isMe = message.senderId == _myId;
final prevMessage =
msgIndex > 0 ? _messages[msgIndex - 1] : null;
@@ -599,14 +620,26 @@ class _ChatScreenState extends State<ChatScreen>
? _messages[msgIndex + 1]
: null;
return MessageBubble(
final bubble = MessageBubble(
message: message,
isMe: isMe,
myId: _myId,
prevMessage: prevMessage,
nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message),
);
if (isMe && message.id == _lastSentId) {
return _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) setState(() => _lastSentId = null);
},
child: bubble,
);
}
return bubble;
},
),
if (_lastFloatingDate != null)
@@ -805,22 +838,33 @@ class _ChatScreenState extends State<ChatScreen>
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _messageController,
style: TextStyle(color: cs.onSurface, fontSize: 16),
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: 'Message',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
child: Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter &&
!HardwareKeyboard.instance.isShiftPressed) {
if (_hasText) _sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: TextField(
controller: _messageController,
style: TextStyle(color: cs.onSurface, fontSize: 16),
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: 'Message',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
),
),
),
),
@@ -892,3 +936,59 @@ class _ChatScreenState extends State<ChatScreen>
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
const _SentMessageAnimation({
super.key,
required this.child,
required this.onComplete,
});
@override
State<_SentMessageAnimation> createState() => _SentMessageAnimationState();
}
class _SentMessageAnimationState extends State<_SentMessageAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _opacity;
late final Animation<double> _slide;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
_slide = Tween<double>(begin: 16, end: 0).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut),
);
_ctrl.forward().whenComplete(widget.onComplete);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) => Opacity(
opacity: _opacity.value,
child: Transform.translate(
offset: Offset(0, _slide.value),
child: child,
),
),
child: widget.child,
);
}
}
+36 -36
View File
@@ -44,6 +44,7 @@ class MessageBubble extends StatelessWidget {
final CachedMessage? prevMessage;
final CachedMessage? nextMessage;
final String chatType;
final String? overrideStatus;
const MessageBubble({
super.key,
@@ -52,7 +53,8 @@ class MessageBubble extends StatelessWidget {
required this.myId,
this.prevMessage,
this.nextMessage,
required this.chatType
required this.chatType,
this.overrideStatus,
});
bool get isGroupedWithNext {
@@ -315,7 +317,6 @@ class MessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (message.isControl) {
debugPrint('BUILD CONTROL: ${message.id}');
return Padding(
padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin),
child: Center(child: _buildControlContent(context)),
@@ -490,7 +491,7 @@ class MessageBubble extends StatelessWidget {
children: [
Flexible(
child: isForwarded
? _buildForwardedInlineText(context, forwarded, textColor)
? _buildForwardedInlineText(context, forwarded!, textColor)
: Text(
message.text ?? '',
style: TextStyle(color: textColor, fontSize: 16, height: 1.3),
@@ -500,7 +501,9 @@ class MessageBubble extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
_formatTime(message.time),
message.status == 'EDITED'
? '${_formatTime(message.time)} ред.'
: _formatTime(message.time),
style: TextStyle(
color: textColor.withValues(alpha: 0.7),
fontSize: 10,
@@ -904,13 +907,6 @@ class MessageBubble extends StatelessWidget {
ForwardedMessageAttachment forwarded,
List<MessageAttachment> attachments,
) {
debugPrint('DEBUG: _buildForwardedGenericContent called');
debugPrint('DEBUG: attachments count: ${attachments.length}');
for (var i = 0; i < attachments.length; i++) {
debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}');
debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}');
}
final cs = Theme.of(context).colorScheme;
final headerColor = isMe
? Colors.white.withValues(alpha: 0.7)
@@ -1671,7 +1667,7 @@ class MessageBubble extends StatelessWidget {
url: url,
textColor: textColor,
isMe: isMe,
status: message.status,
status: overrideStatus ?? message.status,
time: message.time,
cs: cs,
waveData: waveData,
@@ -1729,31 +1725,31 @@ class MessageBubble extends StatelessWidget {
}
Widget _buildStatusIcon(BuildContext context) {
final status = message.status;
final status = overrideStatus ?? message.status;
IconData icon;
Color color;
if (status == null || status == 'sending' || status == 'pending') {
icon = Symbols.check;
color = Colors.white70;
} else {
switch (status) {
case 'sent':
icon = Symbols.check;
color = Colors.white70;
case 'delivered':
icon = Symbols.done_all;
color = Colors.white70;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF34C759);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = Colors.white70;
}
switch (status) {
case 'sending':
case 'pending':
icon = Symbols.schedule;
color = Colors.white70;
case null:
case 'sent':
icon = Symbols.check;
color = Colors.white70;
case 'delivered':
icon = Symbols.done_all;
color = Colors.white70;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF4FC3F7);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;
default:
icon = Symbols.check;
color = Colors.white70;
}
return Icon(icon, size: 14, color: color);
@@ -1840,11 +1836,15 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
IconData icon;
Color color;
if (status == null || status == 'sending' || status == 'pending') {
if (status == null || status == 'sent') {
icon = Symbols.check;
color = Colors.white54;
} else {
switch (status) {
case 'sending':
case 'pending':
icon = Symbols.schedule;
color = Colors.white54;
case 'sent':
icon = Symbols.check;
color = Colors.white54;
@@ -1853,7 +1853,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
color = Colors.white54;
case 'read':
icon = Symbols.done_all;
color = const Color(0xFF34C759);
color = const Color(0xFF4FC3F7);
case 'error':
icon = Symbols.error;
color = Colors.redAccent;