import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../media/public_media_image.dart';
typedef NavigationLabelBuilder = String Function(NavigationItem item);
class AppNavigationBar extends StatelessWidget {
const AppNavigationBar({
super.key,
required this.items,
required this.selectedIndex,
required this.onSelected,
required this.onOpenMenu,
required this.brand,
required this.labelBuilder,
this.showLabels = true,
});
final List<NavigationItem> items;
final int selectedIndex;
final ValueChanged<int> onSelected;
final VoidCallback onOpenMenu;
final BrandConfig brand;
final NavigationLabelBuilder labelBuilder;
final bool showLabels;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final width = MediaQuery.sizeOf(context).width;
final showLabels = this.showLabels && width >= 700;
final visibleItems = items.take(6).toList(growable: false);
final leftCount = (visibleItems.length / 2).ceil();
final left = visibleItems.take(leftCount).toList(growable: false);
final right = visibleItems.skip(leftCount).toList(growable: false);
final content = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var index = 0; index < left.length; index++)
Expanded(
child: _NavigationButton(
item: left[index],
label: labelBuilder(left[index]),
selected: selectedIndex == index,
showLabel: showLabels,
onTap: () => onSelected(index),
),
),
const SizedBox(width: 82),
for (var index = 0; index < right.length; index++)
Expanded(
child: _NavigationButton(
item: right[index],
label: labelBuilder(right[index]),
selected: selectedIndex == leftCount + index,
showLabel: showLabels,
onTap: () => onSelected(leftCount + index),
),
),
],
);
return Material(
color: palette.ink,
elevation: 18,
shadowColor: Colors.black54,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
child: SafeArea(
top: false,
child: SizedBox(
height: showLabels ? 98 : 80,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
content,
Positioned(
bottom: 4,
child: _LogoMenuButton(brand: brand, onTap: onOpenMenu),
),
],
),
),
),
),
),
),
);
}
}
class _NavigationButton extends StatelessWidget {
const _NavigationButton({
required this.item,
required this.label,
required this.selected,
required this.showLabel,
required this.onTap,
});
final NavigationItem item;
final String label;
final bool selected;
final bool showLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final foreground = selected ? Colors.white : Colors.white70;
final background = selected ? palette.brand : Colors.transparent;
return Semantics(
button: true,
selected: selected,
label: label,
child: Align(
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: background,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: showLabel ? 58 : 48,
height: showLabel ? 58 : 48,
child: _NavigationIcon(
item: item,
color: foreground,
size: 23,
),
),
),
),
if (showLabel) ...[
const SizedBox(height: 2),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
],
),
),
);
}
}
class _LogoMenuButton extends StatelessWidget {
const _LogoMenuButton({required this.brand, required this.onTap});
final BrandConfig brand;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final imageUrl = brand.navigationLogoUrl.isNotEmpty
? brand.navigationLogoUrl
: (brand.logoDarkUrl.isNotEmpty ? brand.logoDarkUrl : brand.logoUrl);
return Semantics(
button: true,
label: l10n.menuLabel,
child: Material(
color: Colors.white,
elevation: 12,
shadowColor: Colors.black54,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: 62,
height: 62,
child: Padding(
padding: const EdgeInsets.all(8),
child: imageUrl.isEmpty
? _FallbackLogo(wordmark: brand.wordmark)
: PublicMediaImage(
url: imageUrl,
alt: brand.navigationLogoAlt.isNotEmpty
? brand.navigationLogoAlt
: (brand.logoDarkAlt.isNotEmpty
? brand.logoDarkAlt
: brand.logoAlt),
fit: BoxFit.contain,
isSvg:
brand.navigationLogoUrl.isNotEmpty &&
brand.navigationLogoIsSvg,
emptyIcon: Icons.image_outlined,
errorIcon: Icons.image_outlined,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
includeSemantics: false,
fallbackBuilder: (_) =>
_FallbackLogo(wordmark: brand.wordmark),
),
),
),
),
),
);
}
}
class _NavigationIcon extends StatelessWidget {
const _NavigationIcon({
required this.item,
required this.color,
required this.size,
});
final NavigationItem item;
final Color color;
final double size;
@override
Widget build(BuildContext context) {
if (item.iconUrl.isEmpty) {
return Icon(navigationIcon(item.icon), color: color, size: size);
}
return PublicMediaImage(
url: item.iconUrl,
width: size,
height: size,
fit: BoxFit.contain,
isSvg: item.iconIsSvg,
alt: item.iconAlt.isEmpty ? item.label : item.iconAlt,
iconColor: color,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
includeSemantics: false,
fallbackBuilder: (_) =>
Icon(navigationIcon(item.icon), color: color, size: size),
);
}
}
class _FallbackLogo extends StatelessWidget {
const _FallbackLogo({required this.wordmark});
final String wordmark;
@override
Widget build(BuildContext context) {
final initial = wordmark.trim().isEmpty ? 'M' : wordmark.trim()[0];
return DecoratedBox(
decoration: BoxDecoration(
color: AppTheme.palette(context).brand,
shape: BoxShape.circle,
),
child: Center(
child: Text(
initial,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w800,
),
),
),
);
}
}
class FullscreenModuleMenuPage extends StatelessWidget {
const FullscreenModuleMenuPage({
super.key,
required this.items,
required this.brand,
required this.labelBuilder,
});
final List<NavigationItem> items;
final BrandConfig brand;
final NavigationLabelBuilder labelBuilder;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final l10n = AppLocalizations.of(context);
final width = MediaQuery.sizeOf(context).width;
final columns = width >= 900 ? 4 : 3;
final menuItems = items
.where((item) => item.enabled && item.id != 'more')
.toList(growable: false);
return Scaffold(
backgroundColor: palette.ink,
appBar: AppBar(
backgroundColor: palette.ink,
foregroundColor: Colors.white,
title: Text(l10n.menuLabel),
actions: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: l10n.close,
),
],
),
body: SafeArea(
top: false,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1040),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 22),
child: Column(
children: [
_MenuLogo(brand: brand),
const SizedBox(height: 16),
Text(
brand.wordmark,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 32),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate((context, index) {
final item = menuItems[index];
return _ModuleTile(
item: item,
label: labelBuilder(item),
onTap: () => Navigator.of(context).pop(item),
);
}, childCount: menuItems.length),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 22,
crossAxisSpacing: 14,
mainAxisExtent: 136,
),
),
),
],
),
),
),
),
);
}
}
class _MenuLogo extends StatelessWidget {
const _MenuLogo({required this.brand});
final BrandConfig brand;
@override
Widget build(BuildContext context) {
final imageUrl = brand.logoDarkUrl.isNotEmpty
? brand.logoDarkUrl
: brand.logoUrl;
return SizedBox(
height: 86,
width: 180,
child: imageUrl.isEmpty
? _FallbackLogo(wordmark: brand.wordmark)
: Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (_, _, _) =>
_FallbackLogo(wordmark: brand.wordmark),
),
);
}
}
class _ModuleTile extends StatelessWidget {
const _ModuleTile({
required this.item,
required this.label,
required this.onTap,
});
final NavigationItem item;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: label,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 66,
height: 66,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white54, width: 1.5),
color: Colors.white.withValues(alpha: .04),
),
child: _NavigationIcon(
item: item,
color: Colors.white,
size: 31,
),
),
const SizedBox(height: 10),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.15,
),
),
],
),
),
),
);
}
}
IconData navigationIcon(String icon) => switch (icon) {
'explore' => Icons.explore_outlined,
'map' => Icons.map_outlined,
'event' => Icons.event_outlined,
'people' => Icons.people_outline,
'bookmark' => Icons.bookmark_border,
'account_balance' => Icons.account_balance_outlined,
'sell' => Icons.sell_outlined,
'work' => Icons.work_outline,
'home_work' => Icons.home_work_outlined,
'article' => Icons.article_outlined,
'menu_book' => Icons.menu_book_outlined,
'menu' => Icons.menu,
'notifications' => Icons.notifications_none_outlined,
'person' => Icons.person_outline,
_ => Icons.grid_view_outlined,
};
