diff --git a/lib/pages/home/widgets/bottom_nav_bar.dart b/lib/pages/home/widgets/bottom_nav_bar.dart index a17f98c..32c44f1 100644 --- a/lib/pages/home/widgets/bottom_nav_bar.dart +++ b/lib/pages/home/widgets/bottom_nav_bar.dart @@ -26,11 +26,53 @@ class BottomNavBar extends StatefulWidget { } class _BottomNavBarState extends State { + int _prevShift = 0; + + @override + void didUpdateWidget(covariant BottomNavBar oldWidget) { + super.didUpdateWidget(oldWidget); + // prevShift will be updated in build after computing desiredShift. Keep + // previous value so AnimatedSwitcher transition direction can be derived. + // No-op here: _prevShift is updated in build to avoid extra setState. + } + @override Widget build(BuildContext context) { + // Minimal change approach: render all items in a fixed-width row but clip to + // show only 5 slots. Animate a horizontal translation so one end icon slides + // off-screen depending on the active page. + const horizontalPadding = 10.0; // matches previous symmetric horizontal padding + const visibleCount = 5; + const items = [ + PageItem.feed, + PageItem.events, + PageItem.mensa, + PageItem.navigation, + PageItem.wallet, + PageItem.more, + ]; + final totalItems = items.length; + final maxShift = (totalItems - visibleCount).clamp(0, totalItems); + final activeIndex = items.indexOf(widget.currentPage); + // Aim to keep the active item roughly centered when possible; because + // totalItems-visibleCount == 1 here, shift will be 0 or 1 which matches the + // requested behavior: when on first page show first 5, when on last show last 5. + final desiredShift = (activeIndex - 2).clamp(0, maxShift); + + // Compute height based on platform base and device bottom inset to avoid + // overflow when system navigation/home bars reduce available height. + final bottomInset = MediaQuery.of(context).padding.bottom; + // 66 = 26 Icon + 2*8 Vertical Padding + 14 Label Text + 12 Active Animation + const navbarHeight = 68; + return Container( - height: Platform.isIOS ? 88 : 98, - padding: Platform.isIOS ? const EdgeInsets.only(bottom: 20, left: 5) : const EdgeInsets.only(left: 7), + height: bottomInset + navbarHeight, // System UI + Campus App Navbar + // keep left padding but add bottom padding equal to the system inset so + // the visual content is above system UI while the container remains + // flush at the page bottom. + padding: Platform.isIOS + ? EdgeInsets.only(left: 5, bottom: bottomInset) + : EdgeInsets.only(left: 7, bottom: bottomInset), decoration: BoxDecoration( color: Provider.of(context).currentThemeData.cardColor, borderRadius: const BorderRadius.only( @@ -45,67 +87,108 @@ class _BottomNavBarState extends State { ), ], ), - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // News Feed - BottomNavBarItem( - title: 'Feed', - imagePathActive: 'assets/img/icons/home-filled.png', - imagePathInactive: 'assets/img/icons/home-outlined.png', - onTap: () => widget.onSelectedPage(PageItem.feed), - isActive: widget.currentPage == PageItem.feed, - iconPaddingLeft: 0, - ), - // Calendar - BottomNavBarItem( - title: 'Events', - imagePathActive: 'assets/img/icons/calendar-filled.png', - imagePathInactive: 'assets/img/icons/calendar-outlined.png', - onTap: () => widget.onSelectedPage(PageItem.events), - isActive: widget.currentPage == PageItem.events, - iconPaddingLeft: 14, - ), - // Mensa - BottomNavBarItem( - title: 'Mensa', - imagePathActive: 'assets/img/icons/mensa-filled.png', - imagePathInactive: 'assets/img/icons/mensa-outlined.png', - onTap: () => widget.onSelectedPage(PageItem.mensa), - isActive: widget.currentPage == PageItem.mensa, - ), - // Navigation - BottomNavBarItem( - title: 'Navigation', - imagePathActive: 'assets/img/icons/map-filled.png', - imagePathInactive: 'assets/img/icons/map-outlined.png', - onTap: () => widget.onSelectedPage(PageItem.navigation), - isActive: widget.currentPage == PageItem.navigation, - ), - // Wallet - BottomNavBarItem( - title: 'Wallet', - imagePathActive: 'assets/img/icons/wallet-filled.png', - imagePathInactive: 'assets/img/icons/wallet-outlined.png', - onTap: () => widget.onSelectedPage(PageItem.wallet), - isActive: widget.currentPage == PageItem.wallet, - ), - // More - BottomNavBarItem( - title: 'Mehr', - imagePathActive: 'assets/img/icons/more.png', - imagePathInactive: 'assets/img/icons/more.png', - onTap: () => widget.onSelectedPage(PageItem.more), - isActive: widget.currentPage == PageItem.more, - iconPaddingLeft: 5, - iconPaddingRight: 0, - ), - ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: horizontalPadding), + child: ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final navHeight = constraints.maxHeight; + final effectiveContainerWidth = constraints.maxWidth; + final computedSlotWidth = effectiveContainerWidth / visibleCount; + + // Determine which slice of items to show (no off-screen items) + final startIndex = desiredShift; + final visibleItems = items.sublist(startIndex, startIndex + visibleCount); + + // Decide animation direction based on previous shift + final animateForward = desiredShift >= _prevShift; + // Update prevShift for next frame + _prevShift = desiredShift; + + return SizedBox( + height: navHeight, + width: effectiveContainerWidth, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final offsetAnimation = Tween( + begin: Offset(animateForward ? 1.0 : -1.0, 0), + end: Offset.zero, + ).animate(animation); + return SlideTransition(position: offsetAnimation, child: child); + }, + child: SizedBox( + width: effectiveContainerWidth, + key: ValueKey(desiredShift), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final p in visibleItems) + SizedBox( + width: computedSlotWidth, + child: (() { + switch (p) { + case PageItem.feed: + return BottomNavBarItem( + title: 'Feed', + imagePathActive: 'assets/img/icons/home-filled.png', + imagePathInactive: 'assets/img/icons/home-outlined.png', + onTap: () => widget.onSelectedPage(PageItem.feed), + isActive: widget.currentPage == PageItem.feed, + ); + case PageItem.events: + return BottomNavBarItem( + title: 'Events', + imagePathActive: 'assets/img/icons/calendar-filled.png', + imagePathInactive: 'assets/img/icons/calendar-outlined.png', + onTap: () => widget.onSelectedPage(PageItem.events), + isActive: widget.currentPage == PageItem.events, + iconPaddingLeft: 14, + ); + case PageItem.mensa: + return BottomNavBarItem( + title: 'Mensa', + imagePathActive: 'assets/img/icons/mensa-filled.png', + imagePathInactive: 'assets/img/icons/mensa-outlined.png', + onTap: () => widget.onSelectedPage(PageItem.mensa), + isActive: widget.currentPage == PageItem.mensa, + ); + case PageItem.navigation: + return BottomNavBarItem( + title: 'Navigation', + imagePathActive: 'assets/img/icons/map-filled.png', + imagePathInactive: 'assets/img/icons/map-outlined.png', + onTap: () => widget.onSelectedPage(PageItem.navigation), + isActive: widget.currentPage == PageItem.navigation, + ); + case PageItem.wallet: + return BottomNavBarItem( + title: 'Wallet', + imagePathActive: 'assets/img/icons/wallet-filled.png', + imagePathInactive: 'assets/img/icons/wallet-outlined.png', + onTap: () => widget.onSelectedPage(PageItem.wallet), + isActive: widget.currentPage == PageItem.wallet, + ); + case PageItem.more: + return BottomNavBarItem( + title: 'Mehr', + imagePathActive: 'assets/img/icons/more.png', + imagePathInactive: 'assets/img/icons/more.png', + onTap: () => widget.onSelectedPage(PageItem.more), + isActive: widget.currentPage == PageItem.more, + iconPaddingLeft: 5, + ); + default: + return const SizedBox.shrink(); + } + })(), + ), + ], + ), + ), + ), + ); + }, ), ), ), diff --git a/lib/pages/home/widgets/bottom_nav_bar_item.dart b/lib/pages/home/widgets/bottom_nav_bar_item.dart index cea40b1..e31a759 100644 --- a/lib/pages/home/widgets/bottom_nav_bar_item.dart +++ b/lib/pages/home/widgets/bottom_nav_bar_item.dart @@ -30,7 +30,7 @@ class BottomNavBarItem extends StatefulWidget { /// Callback that should be called whenever the button is tapped final VoidCallback onTap; - /// Wether the refered page is the currently displayed one + /// Whether the referred page is the currently displayed one final bool isActive; const BottomNavBarItem({ @@ -38,9 +38,9 @@ class BottomNavBarItem extends StatefulWidget { required this.imagePathActive, required this.imagePathInactive, required this.title, - this.iconVerticalPadding = 10, - this.iconPaddingLeft = 10, - this.iconPaddingRight = 10, + this.iconVerticalPadding = 8, + this.iconPaddingLeft = 0, + this.iconPaddingRight = 0, required this.onTap, this.isActive = false, }); @@ -64,52 +64,49 @@ class _BottomNavBarItemState extends State { return Padding( padding: EdgeInsets.only(left: widget.iconPaddingLeft, right: widget.iconPaddingRight), child: AnimatedPadding( - padding: widget.isActive ? const EdgeInsets.only(top: 2) : const EdgeInsets.only(top: 11), + padding: widget.isActive ? const EdgeInsets.only(top: 2) : const EdgeInsets.only(top: 6), duration: animationDuration, curve: animationCurve, child: Column( - mainAxisSize: MainAxisSize.min, children: [ - // Icon-button - CustomButton( - tapHandler: () => widget.onTap(), - child: Padding( - padding: EdgeInsets.only( - top: widget.iconVerticalPadding, - bottom: widget.iconVerticalPadding, - ), - child: Image.asset( - widget.isActive ? widget.imagePathActive : widget.imagePathInactive, - height: iconHeight, - color: widget.isActive - ? Provider.of(context).currentThemeData.colorScheme.secondary - : Provider.of(context, listen: false).currentTheme == AppThemes.light - ? Colors.black - : const Color.fromRGBO(184, 186, 191, 1), - /* Provider.of(context, listen: false).currentTheme == AppThemes.light - ? widget.isActive + // Icon-button wrapped so it can flex if parent is constrained + Flexible( + child: CustomButton( + tapHandler: () => widget.onTap(), + child: Padding( + padding: EdgeInsets.only( + top: widget.iconVerticalPadding, + bottom: widget.iconVerticalPadding, + ), + child: Image.asset( + widget.isActive ? widget.imagePathActive : widget.imagePathInactive, + height: iconHeight, + color: widget.isActive ? Provider.of(context).currentThemeData.colorScheme.secondary - : Colors.black - : widget.isActive - ? const Color.fromRGBO(255, 107, 1, 1) - : const Color.fromRGBO(184, 186, 191, 1), */ - filterQuality: FilterQuality.high, + : Provider.of(context, listen: false).currentTheme == AppThemes.light + ? Colors.black + : const Color.fromRGBO(184, 186, 191, 1), + filterQuality: FilterQuality.high, + ), ), ), ), - // Text - AnimatedPadding( - padding: widget.isActive ? EdgeInsets.zero : const EdgeInsets.only(top: 10), + + // Text: keep single line and avoid reserving vertical space when + // inactive. Use maxLines:1 to guarantee no wrapping. + AnimatedSwitcher( duration: animationDuration, - curve: animationCurve, - child: AnimatedOpacity( - opacity: widget.isActive ? 1 : 0, - duration: animationDuration, - child: Text( - widget.title, - style: Provider.of(context).currentThemeData.textTheme.labelSmall, - ), - ), + switchInCurve: animationCurve, + switchOutCurve: animationCurve, + child: widget.isActive + ? Text( + widget.title, + key: ValueKey('title-${widget.title}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Provider.of(context).currentThemeData.textTheme.labelSmall, + ) + : const SizedBox.shrink(key: ValueKey('title-empty')), ), ], ),