Files
Qlyra/lib/frontend/screens/chats/chat_screen.dart
T

994 lines
32 KiB
Dart

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';
import '../../../main.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/attachment_panel.dart';
class _DateSeparatorItem {
final DateTime date;
final GlobalKey key;
_DateSeparatorItem(this.date, this.key);
}
class _MessageItem {
final CachedMessage message;
final int index;
const _MessageItem(this.message, this.index);
}
class ChatScreen extends StatefulWidget {
final int chatId;
final String name;
final String imageUrl;
final String chatType;
const ChatScreen({
super.key,
required this.chatId,
required this.name,
required this.imageUrl,
required this.chatType,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen>
with TickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final GlobalKey _listKey = GlobalKey();
bool _hasText = false;
bool _isLoading = true;
bool _showAttachmentPanel = false;
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
int _myId = 0;
CachedChat? chat;
DateTime? _floatingDate;
DateTime? _lastFloatingDate;
Timer? _floatingDateTimer;
late final AnimationController _floatingDateAnimController;
final Map<int, GlobalKey> _separatorKeys = {};
double _lastScrollOffset = 0;
String? _lastSentId;
@override
void initState() {
super.initState();
_messageController.addListener(_onTextChanged);
_scrollController.addListener(_onScrollForDate);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_floatingDateAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
reverseDuration: const Duration(milliseconds: 380),
);
_loadHistory();
}
Future<void> _loadHistory() async {
final activeProfile = await AppDatabase.loadActiveProfile();
_myId = activeProfile?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
if (mounted && value.isNotEmpty) {
setState(() { chat = value.first; });
}
}).catchError((_) {});
final cachedRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 100,
);
if (mounted && cachedRows.isNotEmpty) {
setState(() {
_messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList();
_messages.sort((a, b) => a.time.compareTo(b.time));
if (api.state == SessionState.online) {
_isLoading = false;
}
});
}
try {
await messagesModule.fetchHistory(_myId, widget.chatId);
final updatedRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 100,
);
if (mounted) {
setState(() {
_messages = updatedRows
.map((r) => CachedMessage.fromDbRow(r))
.toList();
_messages.sort((a, b) => a.time.compareTo(b.time));
_isLoading = false;
});
}
_loadForwardedSenderNames();
} catch (e) {
debugPrint('Error fetching history: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
void dispose() {
_messageController.removeListener(_onTextChanged);
_scrollController.removeListener(_onScrollForDate);
_floatingDateTimer?.cancel();
_floatingDateAnimController.dispose();
_messageController.dispose();
_scrollController.dispose();
_shimmerController.dispose();
super.dispose();
}
void _onTextChanged() {
final bool newHasText = _messageController.text.trim().isNotEmpty;
if (newHasText != _hasText) {
setState(() {
_hasText = newHasText;
});
}
}
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;
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final now = DateTime.now().millisecondsSinceEpoch;
try {
final tempMessage = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'sending',
);
setState(() {
_lastSentId = tempId;
_messages.add(tempMessage);
_messageController.clear();
_hasText = false;
});
// Instant tactile "whoosh" the moment the message leaves the composer,
// not after the network round-trip — feedback must feel immediate.
Haptics.send();
_scrollToBottom();
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) {
setState(() {
_messages[index] = CachedMessage(
id: actualId.isNotEmpty ? actualId : tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'sent',
);
});
}
} catch (e) {
Haptics.error();
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',
);
});
}
}
}
Future<void> _loadForwardedSenderNames() async {
final forwardIds = <int>{};
for (final msg in _messages) {
if (msg.attachments != null) {
for (final a in msg.attachments!) {
if (a is ForwardedMessageAttachment) {
if (a.originalSenderName == null) {
forwardIds.add(a.originalSenderId);
}
}
}
}
}
if (forwardIds.isEmpty) return;
for (final id in forwardIds) {
final name = await messagesModule.searchContactById(id);
final avatar = ContactCache.getAvatar(id);
if (name != null && mounted) {
setState(() {
for (var i = 0; i < _messages.length; i++) {
final msg = _messages[i];
if (msg.attachments != null) {
final newAttaches = msg.attachments!.map((a) {
if (a is ForwardedMessageAttachment &&
a.originalSenderId == id &&
a.originalSenderName == null) {
return ForwardedMessageAttachment(
originalSenderId: id,
originalSenderName: name,
originalSenderAvatar: avatar,
originalMessageId: a.originalMessageId,
originalTime: a.originalTime,
originalText: a.originalText,
originalChatId: a.originalChatId,
originalAttachments: a.originalAttachments,
originalContact: a.originalContact,
);
}
return a;
}).toList();
_messages[i] = CachedMessage(
id: msg.id,
accountId: msg.accountId,
chatId: msg.chatId,
senderId: msg.senderId,
text: msg.text,
time: msg.time,
status: msg.status,
payload: msg.payload,
attachments: newAttaches,
);
}
}
});
}
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.minScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
List<Object> _buildCombinedItems() {
final List<Object> items = [];
final Set<int> usedDates = {};
for (int i = 0; i < _messages.length; i++) {
final msg = _messages[i];
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day)
.millisecondsSinceEpoch;
bool needSeparator = i == 0;
if (!needSeparator) {
final prevDate =
DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time);
final prevDayMillis =
DateTime(prevDate.year, prevDate.month, prevDate.day)
.millisecondsSinceEpoch;
needSeparator = dayMillis != prevDayMillis;
}
if (needSeparator) {
_separatorKeys.putIfAbsent(dayMillis, () => GlobalKey());
usedDates.add(dayMillis);
items.add(_DateSeparatorItem(
DateTime.fromMillisecondsSinceEpoch(dayMillis),
_separatorKeys[dayMillis]!,
));
}
items.add(_MessageItem(msg, i));
}
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
return items;
}
void _onScrollForDate() {
if (!_scrollController.hasClients) return;
final currentOffset = _scrollController.position.pixels;
final scrollingUp = currentOffset > _lastScrollOffset;
_lastScrollOffset = currentOffset;
_floatingDateTimer?.cancel();
if (!scrollingUp) {
_floatingDateAnimController.reverse();
return;
}
_floatingDateTimer = Timer(const Duration(seconds: 2), () {
if (mounted) _floatingDateAnimController.reverse();
});
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate());
}
void _updateFloatingDate() {
if (!mounted) return;
DateTime? result;
final listRenderBox = _listKey.currentContext?.findRenderObject();
if (listRenderBox is! RenderBox) return;
_separatorKeys.forEach((dayMillis, gkey) {
final ctx = gkey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject();
if (box is! RenderBox) return;
final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox);
if (pos.dy + box.size.height < 4) {
final date = DateTime.fromMillisecondsSinceEpoch(dayMillis);
if (result == null || date.isAfter(result!)) {
result = date;
}
}
});
if (result == null) return;
final bool dateChanged = result != _lastFloatingDate;
_lastFloatingDate = result;
if (result != _floatingDate) {
setState(() => _floatingDate = result);
}
if (dateChanged) {
_floatingDateAnimController.forward(from: 0);
} else {
_floatingDateAnimController.forward();
}
}
String _formatDateLabel(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final d = DateTime(date.year, date.month, date.day);
if (d == today) return 'Сегодня';
if (d == yesterday) return 'Вчера';
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
if (date.year == now.year) {
return '${date.day} ${months[date.month - 1]}';
}
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
Widget _buildDateSeparatorWidget(BuildContext context, DateTime date,
{Key? key}) {
final cs = Theme.of(context).colorScheme;
return Padding(
key: key,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDateLabel(date),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// TODO: Локализация
// TODO: Cклонения
final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently";
return Scaffold(
backgroundColor: cs.surface,
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatInfoScreen(
chatId: widget.chatId,
name: widget.name,
imageUrl: widget.imageUrl,
chatType: widget.chatType)
)
),
child: AppBar(
backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
iconTheme: IconThemeData(color: cs.onSurface),
leading: IconButton(
icon: const Icon(Symbols.arrow_back, weight: 400),
onPressed: () => Navigator.pop(context),
),
titleSpacing: 0,
title: Row(
children: [
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
)
else
CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chat?.isOfficial ?? false) ...[
const SizedBox(width: 4),
Icon(
Symbols.verified,
color: cs.primary,
size: 16,
weight: 600,
fill: 1,
),
],
],
),
Text(
status,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Symbols.call, weight: 400),
onPressed: () {},
),
IconButton(
icon: const Icon(Symbols.more_vert, weight: 400),
onPressed: () {},
),
],
),
)),
body: Stack(
children: [
Column(
children: [
Expanded(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
: _buildMessagesList(),
),
_buildInputArea(context),
],
),
if (_showAttachmentPanel)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AttachmentPanel(
chatId: widget.chatId,
onClose: () => setState(() => _showAttachmentPanel = false),
),
),
],
),
);
}
Widget _buildMessagesList() {
if (_messages.isEmpty) {
return Center(
child: Text(
'No messages yet',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
final items = _buildCombinedItems();
return Stack(
key: _listKey,
children: [
ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
cacheExtent: 9999,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[items.length - 1 - index];
if (item is _DateSeparatorItem) {
return _buildDateSeparatorWidget(context, item.date,
key: item.key);
}
final msgItem = item as _MessageItem;
final message = msgItem.message;
final msgIndex = msgItem.index;
final isMe = message.senderId == _myId;
final prevMessage =
msgIndex > 0 ? _messages[msgIndex - 1] : null;
final nextMessage = msgIndex < _messages.length - 1
? _messages[msgIndex + 1]
: null;
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)
Positioned(
top: 8,
left: 0,
right: 0,
child: IgnorePointer(
child: AnimatedBuilder(
animation: _floatingDateAnimController,
builder: (context, child) {
final t = CurvedAnimation(
parent: _floatingDateAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
).value;
return Opacity(
opacity: t,
child: Transform.scale(
scale: 0.82 + 0.18 * t,
child: child,
),
);
},
child: _buildDateSeparatorWidget(context, _lastFloatingDate!),
),
),
),
],
);
}
Widget _buildShimmerLoading() {
return AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
final cs = Theme.of(context).colorScheme;
final placeholder = cs.surfaceContainerHighest;
final opacity = 0.3 + (0.4 * _shimmerController.value);
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 8,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final hasImage = index % 3 == 0;
final hasReactions = index % 2 == 0;
final width1 = 60.0 + (index * 15 % 50);
final width2 = 120.0 + (index * 25 % 80);
return Opacity(
opacity: opacity,
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: placeholder,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: width1,
height: 10,
decoration: BoxDecoration(
color: placeholder,
borderRadius: BorderRadius.circular(5),
),
),
const SizedBox(height: 6),
Container(
width: width2,
height: 32,
decoration: BoxDecoration(
color: placeholder,
borderRadius: BorderRadius.circular(10),
),
),
if (hasImage) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
color: placeholder,
borderRadius: BorderRadius.circular(12),
),
),
],
if (hasReactions) ...[
const SizedBox(height: 8),
Row(
children: List.generate(
3,
(i) => Container(
width: 32,
height: 16,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: placeholder,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),
],
),
),
);
},
);
},
);
}
Widget _buildInputArea(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
if (widget.chatType == "CHANNEL") {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: GestureDetector(
onTap: () {},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
),
child: Center(
child: Text(
'Отключить уведомления',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
constraints: const BoxConstraints(
minHeight: 54,
maxHeight: 180,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
cs.surfaceContainerHighest.withValues(alpha: 0.92),
cs.surface,
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: cs.outlineVariant.withValues(alpha: 0.5),
width: 0.5,
),
),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
const SizedBox(width: 12),
Expanded(
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,
),
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _hasText ? 0 : 36,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _hasText ? 0 : 1,
child: _hasText
? const SizedBox.shrink()
: GestureDetector(
onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true),
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Stack(
alignment: Alignment.center,
children: [
if (_showAttachmentPanel)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.primary,
),
),
Icon(
Symbols.attachment,
color: _showAttachmentPanel
? cs.onSurfaceVariant.withValues(alpha: 0.3)
: mutedIcon,
size: 24,
weight: 400,
),
],
),
),
),
),
),
],
),
),
),
const SizedBox(width: 8),
Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hasText ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: GestureDetector(
onTap: _hasText ? _sendMessage : null,
child: Icon(
_hasText ? Symbols.send : Symbols.mic,
color: _hasText ? cs.onPrimary : cs.onSurface,
size: 24,
weight: 400,
),
),
),
],
),
),
);
}
}
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,
);
}
}