From 62926720ca199a366468c358fdda95d31043e70a Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 16:26:19 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B,=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BB=20=D0=BA=D0=B0=D0=BA=D1=83=D1=8E=20?= =?UTF-8?q?=D1=82=D0=BE=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 5 +- lib/frontend/screens/chats/chat_screen.dart | 259 ++++++++++++++++-- lib/main.dart | 8 +- 3 files changed, 242 insertions(+), 30 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2de522b..13ebc9c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1341,7 +1341,9 @@ class _ChatListScreenState extends State ), ), ), - Row( + SizedBox( + width: navInnerW, + child: Row( children: List.generate(4, (index) { IconData icon; String label; @@ -1389,6 +1391,7 @@ class _ChatListScreenState extends State ), ); }), + ), ), ], ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 068939c..e37aca3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -14,6 +14,18 @@ import '../../../backend/modules/messages.dart' show ContactCache; 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; @@ -33,9 +45,10 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; bool _isSending = false; @@ -44,15 +57,28 @@ class _ChatScreenState extends State List _messages = []; int _myId = 0; CachedChat? chat; + + DateTime? _floatingDate; + DateTime? _lastFloatingDate; + Timer? _floatingDateTimer; + late final AnimationController _floatingDateAnimController; + final Map _separatorKeys = {}; + double _lastScrollOffset = 0; @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(); } @@ -111,6 +137,9 @@ class _ChatScreenState extends State @override void dispose() { _messageController.removeListener(_onTextChanged); + _scrollController.removeListener(_onScrollForDate); + _floatingDateTimer?.cancel(); + _floatingDateAnimController.dispose(); _messageController.dispose(); _scrollController.dispose(); _shimmerController.dispose(); @@ -257,6 +286,143 @@ class _ChatScreenState extends State }); } + List _buildCombinedItems() { + final List items = []; + final Set 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; @@ -402,31 +568,74 @@ class _ChatScreenState extends State ); } - return ListView.builder( - controller: _scrollController, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, index) { - final message = _messages[_messages.length - 1 - index]; - debugPrint('LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); - final isMe = message.senderId == _myId; - final prevMessage = index < _messages.length - 1 - ? _messages[_messages.length - 2 - index] - : null; - final nextMessage = index > 0 - ? _messages[_messages.length - index] - : null; + final items = _buildCombinedItems(); - return MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - ); - }, + 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; + 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; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; + + return MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + ); + }, + ), + 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!), + ), + ), + ), + ], ); } diff --git a/lib/main.dart b/lib/main.dart index e78be9a..c7623f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -281,16 +281,16 @@ class _StartupScreenState extends State<_StartupScreen> { return; } + try { + await accountModule.login(accountId: accountId); + } catch (_) {} + if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const ChatListScreen()), ); } - - try { - await accountModule.login(accountId: accountId); - } catch (_) {} } void _goToLogin() {