feat: Add client main shell with bottom navigation

Introduces the new client_main feature package, providing a main shell with bottom navigation for the client app. Updates routing to use /client-main instead of /client-home, adds localization for new tabs, and implements navigation logic, UI, and tests for the main shell. Also refactors create_order module bindings and cleans up unused dependencies.
This commit is contained in:
Achintha Isuru
2026-01-23 09:35:46 -05:00
parent f5a57c7208
commit a964fcabd7
22 changed files with 492 additions and 885 deletions

View File

@@ -0,0 +1,4 @@
library;
export 'src/client_main_module.dart';
export 'src/presentation/navigation/client_main_navigator.dart';

View File

@@ -0,0 +1,46 @@
import 'package:client_home/client_home.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'presentation/blocs/client_main_cubit.dart';
import 'presentation/pages/client_main_page.dart';
import 'presentation/pages/placeholder_page.dart';
class ClientMainModule extends Module {
@override
void binds(Injector i) {
i.addSingleton(ClientMainCubit.new);
}
@override
void routes(RouteManager r) {
r.child(
'/',
child: (BuildContext context) => const ClientMainPage(),
children: <ParallelRoute<dynamic>>[
ModuleRoute<dynamic>('/home', module: ClientHomeModule()),
// Placeholders for other tabs
ChildRoute<dynamic>(
'/coverage',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Coverage'),
),
ChildRoute<dynamic>(
'/billing',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Billing'),
),
ChildRoute<dynamic>(
'/orders',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Orders'),
),
ChildRoute<dynamic>(
'/reports',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Reports'),
),
],
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'client_main_state.dart';
class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
ClientMainCubit() : super(const ClientMainState()) {
Modular.to.addListener(_onRouteChanged);
_onRouteChanged();
}
void _onRouteChanged() {
final String path = Modular.to.path;
int newIndex = state.currentIndex;
// Detect which tab is active based on the route path
// Using contains() to handle child routes and trailing slashes
if (path.contains('/client-main/coverage')) {
newIndex = 0;
} else if (path.contains('/client-main/billing')) {
newIndex = 1;
} else if (path.contains('/client-main/home')) {
newIndex = 2;
} else if (path.contains('/client-main/orders')) {
newIndex = 3;
} else if (path.contains('/client-main/reports')) {
newIndex = 4;
}
if (newIndex != state.currentIndex) {
emit(state.copyWith(currentIndex: newIndex));
}
}
void navigateToTab(int index) {
if (index == state.currentIndex) return;
switch (index) {
case 0:
Modular.to.navigate('/client-main/coverage');
break;
case 1:
Modular.to.navigate('/client-main/billing');
break;
case 2:
Modular.to.navigate('/client-main/home');
break;
case 3:
Modular.to.navigate('/client-main/orders');
break;
case 4:
Modular.to.navigate('/client-main/reports');
break;
}
// State update will happen via _onRouteChanged
}
@override
void dispose() {
Modular.to.removeListener(_onRouteChanged);
close();
}
}

View File

@@ -0,0 +1,16 @@
import 'package:equatable/equatable.dart';
class ClientMainState extends Equatable {
const ClientMainState({
this.currentIndex = 2, // Default to Home
});
final int currentIndex;
ClientMainState copyWith({int? currentIndex}) {
return ClientMainState(currentIndex: currentIndex ?? this.currentIndex);
}
@override
List<Object> get props => <Object>[currentIndex];
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_modular/flutter_modular.dart';
/// Extension to provide typed navigation for the Client Main feature.
extension ClientMainNavigator on IModularNavigator {
/// Navigates to the Client Main Shell (Home).
/// This replaces the current navigation stack.
void navigateClientMain() {
navigate('/client-main/');
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_main_cubit.dart';
import '../blocs/client_main_state.dart';
import '../widgets/client_main_bottom_bar.dart';
/// The main page for the Client app, acting as a shell for the bottom navigation.
///
/// It follows KROW Clean Architecture by:
/// - Being a [StatelessWidget].
/// - Delegating state management to [ClientMainCubit].
/// - Using [RouterOutlet] for nested navigation.
class ClientMainPage extends StatelessWidget {
const ClientMainPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<ClientMainCubit>(
create: (BuildContext context) => Modular.get<ClientMainCubit>(),
child: Scaffold(
extendBody: true,
body: const RouterOutlet(),
bottomNavigationBar: BlocBuilder<ClientMainCubit, ClientMainState>(
builder: (BuildContext context, ClientMainState state) {
return ClientMainBottomBar(
currentIndex: state.currentIndex,
onTap: (int index) {
BlocProvider.of<ClientMainCubit>(context).navigateToTab(index);
},
);
},
),
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A placeholder page for features that are not yet implemented.
///
/// This page displays a simple message indicating that the feature
/// is coming soon. It follows the KROW Design System guidelines by:
/// - Using [UiAppBar] for the app bar
/// - Using [UiTypography] for text styling
/// - Using [UiColors] via typography extensions
class PlaceholderPage extends StatelessWidget {
/// Creates a [PlaceholderPage].
///
/// The [title] is displayed in the app bar and used in the
/// "coming soon" message.
const PlaceholderPage({required this.title, super.key});
/// The title of the feature being displayed.
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: UiAppBar(title: title),
body: Center(
child: Text(
'$title Feature Coming Soon',
style: UiTypography.body1r.textPrimary,
),
),
);
}
}

View File

@@ -0,0 +1,156 @@
import 'dart:ui';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A custom bottom navigation bar for the Client app.
///
/// This widget provides a glassmorphic bottom navigation bar with blur effect
/// and follows the KROW Design System guidelines. It displays five tabs:
/// Coverage, Billing, Home, Orders, and Reports.
///
/// The widget uses:
/// - [UiColors] for all color values
/// - [UiTypography] for text styling
/// - [UiIcons] for icon assets
/// - [UiConstants] for spacing and sizing
class ClientMainBottomBar extends StatelessWidget {
/// Creates a [ClientMainBottomBar].
///
/// The [currentIndex] indicates which tab is currently selected.
/// The [onTap] callback is invoked when a tab is tapped.
const ClientMainBottomBar({
required this.currentIndex,
required this.onTap,
super.key,
});
/// The index of the currently selected tab.
final int currentIndex;
/// Callback invoked when a tab is tapped.
///
/// The callback receives the index of the tapped tab.
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
// Client App colors from design system
const Color activeColor = UiColors.textPrimary;
const Color inactiveColor = UiColors.textInactive;
return Stack(
clipBehavior: Clip.none,
children: <Widget>[
// Glassmorphic background with blur effect
Positioned.fill(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: UiColors.white.withValues(alpha: 0.85),
border: Border(
top: BorderSide(
color: UiColors.black.withValues(alpha: 0.1),
),
),
),
),
),
),
),
// Navigation items
Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2,
top: UiConstants.space4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_buildNavItem(
index: 0,
icon: UiIcons.calendar,
label: t.client_main.tabs.coverage,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 1,
icon: UiIcons.dollar,
label: t.client_main.tabs.billing,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 2,
icon: UiIcons.building,
label: t.client_main.tabs.home,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 3,
icon: UiIcons.file,
label: t.client_main.tabs.orders,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 4,
icon: UiIcons.chart,
label: t.client_main.tabs.reports,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
],
),
),
],
);
}
/// Builds a single navigation item.
///
/// Uses design system tokens for all styling:
/// - Icon size uses a standard value (24px is acceptable for navigation icons)
/// - Spacing uses [UiConstants.space1]
/// - Typography uses [UiTypography.footnote2m]
/// - Colors are passed as parameters from design system
Widget _buildNavItem({
required int index,
required IconData icon,
required String label,
required Color activeColor,
required Color inactiveColor,
}) {
final bool isSelected = currentIndex == index;
return Expanded(
child: GestureDetector(
onTap: () => onTap(index),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Icon(
icon,
color: isSelected ? activeColor : inactiveColor,
size: 24, // Standard navigation icon size
),
const SizedBox(height: UiConstants.space1),
Text(
label,
style: UiTypography.footnote2m.copyWith(
color: isSelected ? activeColor : inactiveColor,
),
),
],
),
),
);
}
}