From e78acfdbf2011209bc9056e0369f20a18e96baea Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 23:24:32 +0300 Subject: [PATCH] Enhance chat list screen with navigation animation and dynamic tab selection. Introduced animation controller for smoother transitions and implemented drag handling for the bottom navigation bar. --- .../screens/chats/chat_list_screen.dart | 767 +++++++++++------- 1 file changed, 494 insertions(+), 273 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 273f7b0..78d1aaf 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,6 +26,12 @@ class _ChatListScreenState extends State with TickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; + bool _navDragging = false; + double _navDragDx = 0; + double _navDragBaseLeft = 0; + late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; + double _navPageAnimEnd = 0; bool _isFabOpen = false; bool _showCacheWarning = false; late AnimationController _fabController; @@ -83,6 +89,13 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _navPageAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -345,6 +358,7 @@ class _ChatListScreenState extends State void dispose() { _stateSub?.cancel(); _fabController.dispose(); + _navPageAnimController.dispose(); _storiesRevealController ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) @@ -353,6 +367,41 @@ class _ChatListScreenState extends State super.dispose(); } + double _effectivePageNavRowT({ + required double inactiveWidth, + required double Function(int index) bubbleLeftForIndex, + }) { + if (_navDragging) { + final left = (_navDragBaseLeft + _navDragDx) + .clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3)); + return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); + } + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } + return _currentNavIndex.toDouble(); + } + + void _onNavTabSelected(int index) { + if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { + return; + } + double fromT; + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } else { + fromT = _currentNavIndex.toDouble(); + } + _navPageAnimStart = fromT; + _navPageAnimEnd = index.toDouble(); + setState(() => _currentNavIndex = index); + _navPageAnimController.forward(from: 0); + } + void _toggleFab() { setState(() { _isFabOpen = !_isFabOpen; @@ -616,6 +665,252 @@ class _ChatListScreenState extends State ); } + Widget _buildChatsTabBody() { + return Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_scrollController.hasClients && _scrollController.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { + _startStoriesAutoClose(); + } + } + } + }, + child: NotificationListener( + onNotification: _onStoriesScrollNotification, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: + _isInitialLoading ? 10 : _chats.length, + ), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDockedBottomNav(ColorScheme cs, double navInnerW) { + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final activeWidth = unitWidth * 2.2; + final inactiveWidth = unitWidth * 1.0; + + double bubbleLeftForIndex(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final leftOffset = bubbleLeftForIndex(_currentNavIndex); + final bubbleW = activeWidth - 8; + final minBubbleLeft = bubbleLeftForIndex(0); + final maxBubbleLeft = bubbleLeftForIndex(3); + + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + _navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + + final navRowT = + ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + + double navInterpolatedWidth(int tabIndex, double rowT) { + final rt = rowT.clamp(0.0, 3.0); + final i0 = rt.floor().clamp(0, 3); + final i1 = rt.ceil().clamp(0, 3); + final frac = i0 == i1 ? 0.0 : (rt - i0); + double at(int sel, int tab) => + (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); + return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; + } + + int indexForBubbleLeft(double left) { + final cx = left + bubbleW / 2; + var best = 0; + var bestD = double.infinity; + for (var i = 0; i < 4; i++) { + final c = bubbleLeftForIndex(i) + bubbleW / 2; + final d = (c - cx).abs(); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: 8, + right: 8, + bottom: _isSelectionMode ? -100 : 10.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + setState(() { + _navDragging = true; + _navDragDx = 0; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + setState(() { + _navDragDx += details.delta.dx; + }); + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft); + final next = indexForBubbleLeft(left); + setState(() { + _currentNavIndex = next; + _navDragging = false; + _navDragDx = 0; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + setState(() { + _navDragging = false; + _navDragDx = 0; + }); + }, + child: Stack( + children: [ + AnimatedPositioned( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: bubbleLeft, + top: 8, + bottom: 8, + width: bubbleW, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), + ), + ), + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: + icon = Symbols.chat_bubble; + label = 'Чаты'; + break; + case 1: + icon = Symbols.call; + label = 'Звонки'; + break; + case 2: + icon = Symbols.person_pin; + label = 'Контакты'; + break; + default: + icon = Symbols.settings; + label = 'Настройки'; + } + + final isSelected = _currentNavIndex == index; + final visualSel = navRowT.round().clamp(0, 3); + return AnimatedContainer( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: _navDragging + ? navInterpolatedWidth(index, navRowT) + : (isSelected + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem( + index, + icon, + label, + selectedOverride: _navDragging + ? (index == visualSel) + : null, + instant: _navDragging, + ), + ), + ); + }), + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -623,286 +918,201 @@ class _ChatListScreenState extends State backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: Stack( - children: [ - IndexedStack( - index: _currentNavIndex, + child: LayoutBuilder( + builder: (context, constraints) { + final pageW = constraints.maxWidth; + final pageH = constraints.maxHeight; + final navInnerW = pageW - 20; + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final inactiveWidth = unitWidth * 1.0; + double bubbleLeftForPageT(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + + final showChatsFab = !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); + + return Stack( children: [ - Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - if (_scrollController.hasClients && - _scrollController.offset <= 0) { - if (pointerSignal.scrollDelta.dy < 0) { - _startStoriesAutoReveal(max(_pullRatio, 0.18)); - } else if (pointerSignal.scrollDelta.dy > 0 && - _pullRatio > 0) { - _startStoriesAutoClose(); - } - } - } - }, - child: NotificationListener( - onNotification: _onStoriesScrollNotification, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPinnedChatsHeader(context), - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && - chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading - ? 10 - : _chats.length, + ClipRect( + child: SizedBox( + width: pageW, + height: pageH, + child: UnconstrainedBox( + constrainedAxis: Axis.vertical, + alignment: Alignment.topLeft, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: pageW * 4, + height: pageH, + child: Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: _buildChatsTabBody(), ), ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const CallsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const ContactsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const SettingsTab(), + ), ), ], ), ), + ), + ), + ), + ), + _buildDockedBottomNav(cs, navInnerW), + ListenableBuilder( + listenable: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: val * 0.2), + ), + ), + ), + if (showChatsFab) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 90 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 90, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ], + ); + }, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), ], ), ), ), - const CallsTab(), - const ContactsTab(), - const SettingsTab(), ], - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - left: 8, - right: 8, - bottom: _isSelectionMode ? -100 : 10.0, - child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; - - double totalWeight = 5.2; - double unitWidth = totalWidth / totalWeight; - double activeWidth = unitWidth * 2.2; - double inactiveWidth = unitWidth * 1.0; - - double leftOffset = 0; - for (int i = 0; i < _currentNavIndex; i++) { - leftOffset += inactiveWidth; - } - - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: leftOffset + 4, - top: 8, - bottom: 8, - width: activeWidth - 8, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - bool isSelected = _currentNavIndex == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem(index, icon, label), - ), - ); - }), - ), - ], - ); - }, - ), - ), - ), - ), - AnimatedBuilder( - animation: _fabController, - builder: (context, child) { - final double val = Curves.easeOutCubic.transform( - _fabController.value, - ); - return Stack( - clipBehavior: Clip.none, - children: [ - if (_fabController.value > 0) - Positioned.fill( - child: GestureDetector( - onTap: _toggleFab, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: val * 0.2), - ), - ), - ), - if (!_isSelectionMode && _currentNavIndex == 0) ...[ - if (_fabController.value > 0) - Positioned( - right: 20, - bottom: 90 + 74, - child: RepaintBoundary( - child: Transform.scale( - scale: val, - alignment: Alignment.bottomRight, - child: Opacity( - opacity: val > 0.5 ? (val - 0.5) * 2 : 0, - child: _buildFabMenu(), - ), - ), - ), - ), - Positioned( - right: 20, - bottom: 90, - child: FloatingActionButton( - onPressed: _toggleFab, - backgroundColor: cs.primaryContainer, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Transform.rotate( - angle: val * (pi / 4), - child: Icon( - Symbols.add, - color: cs.onPrimaryContainer, - size: 28, - weight: 400, - ), - ), - ), - ), - ], - ], - ); - }, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - top: _isSelectionMode ? 0 : -80, - left: 0, - right: 0, - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: cs.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.volume_off, color: cs.onSurface), - onPressed: () {}, - ), - ], - ), - ), - ), - ], + ); + }, ), ), ); @@ -1142,11 +1352,22 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem(int index, IconData icon, String label) { + Widget _buildNavItem( + int index, + IconData icon, + String label, { + bool? selectedOverride, + bool instant = false, + }) { final cs = Theme.of(context).colorScheme; - bool isSelected = _currentNavIndex == index; + final bool isSelected = + selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = + instant ? Duration.zero : const Duration(milliseconds: 350); + final Duration opacityDur = + instant ? Duration.zero : const Duration(milliseconds: 200); return GestureDetector( - onTap: () => setState(() => _currentNavIndex = index), + onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -1163,11 +1384,11 @@ class _ChatListScreenState extends State fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( - duration: const Duration(milliseconds: 350), + duration: animDur, curve: Curves.easeOutCubic, width: isSelected ? null : 0, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: opacityDur, opacity: isSelected ? 1.0 : 0.0, child: Row( mainAxisSize: MainAxisSize.min,