Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 146 additions & 63 deletions lib/pages/home/widgets/bottom_nav_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,53 @@ class BottomNavBar extends StatefulWidget {
}

class _BottomNavBarState extends State<BottomNavBar> {
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>[
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<ThemesNotifier>(context).currentThemeData.cardColor,
borderRadius: const BorderRadius.only(
Expand All @@ -45,67 +87,108 @@ class _BottomNavBarState extends State<BottomNavBar> {
),
],
),
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<Offset>(
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<int>(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();
}
})(),
),
],
),
),
),
);
},
),
),
),
Expand Down
79 changes: 38 additions & 41 deletions lib/pages/home/widgets/bottom_nav_bar_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ 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({
super.key,
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,
});
Expand All @@ -64,52 +64,49 @@ class _BottomNavBarItemState extends State<BottomNavBarItem> {
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<ThemesNotifier>(context).currentThemeData.colorScheme.secondary
: Provider.of<ThemesNotifier>(context, listen: false).currentTheme == AppThemes.light
? Colors.black
: const Color.fromRGBO(184, 186, 191, 1),
/* Provider.of<ThemesNotifier>(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<ThemesNotifier>(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<ThemesNotifier>(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<ThemesNotifier>(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<ThemesNotifier>(context).currentThemeData.textTheme.labelSmall,
)
: const SizedBox.shrink(key: ValueKey('title-empty')),
),
],
),
Expand Down