Skip to content
Merged
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
140 changes: 95 additions & 45 deletions formulus/src/navigation/MainTabNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import AboutScreen from '../screens/AboutScreen';
import HelpScreen from '../screens/HelpScreen';
import MoreScreen from '../screens/MoreScreen';
import { colors } from '../theme/colors';
import { MainTabParamList } from '../types/NavigationTypes';
import AppConfigService from '../services/AppConfigService';
import {
MainTabParamList,
VisibleMainTab,
VISIBLE_MAIN_TABS,
} from '../types/NavigationTypes';
import { useAppTheme } from '../contexts/AppThemeContext';

const Tab = createBottomTabNavigator<MainTabParamList>();
Expand Down Expand Up @@ -41,6 +46,64 @@ const renderMoreIcon = ({ color, size }: TabBarIconProps) => (
<Icon name="menu" size={size} color={color} />
);

const TAB_COMPONENTS: Record<
VisibleMainTab,
React.ComponentType<Record<string, unknown>>
> = {
Home: HomeScreen as React.ComponentType<Record<string, unknown>>,
Forms: FormsScreen as React.ComponentType<Record<string, unknown>>,
Observations: ObservationsScreen as React.ComponentType<
Record<string, unknown>
>,
Sync: SyncScreen as React.ComponentType<Record<string, unknown>>,
More: MoreScreen as React.ComponentType<Record<string, unknown>>,
};

const TAB_ICONS: Record<
VisibleMainTab,
(props: TabBarIconProps) => React.ReactElement
> = {
Home: renderHomeIcon,
Forms: renderFormsIcon,
Observations: renderObservationsIcon,
Sync: renderSyncIcon,
More: renderMoreIcon,
};

const isVisibleMainTab = (value: string): value is VisibleMainTab =>
(VISIBLE_MAIN_TABS as readonly string[]).includes(value);

const getConfiguredTabs = (): {
tabs: VisibleMainTab[];
tabConfigPresent: boolean;
} => {
const config = AppConfigService.getInstance().getConfig();
const requestedTabs = config?.navigation?.tabs;

if (requestedTabs === undefined) {
return { tabs: [...VISIBLE_MAIN_TABS], tabConfigPresent: false };
}

if (!Array.isArray(requestedTabs)) {
console.warn(
'[Navigation] app.config.json navigation.tabs must be an array. Falling back to defaults.',
);
return { tabs: [...VISIBLE_MAIN_TABS], tabConfigPresent: true };
}

const tabs = requestedTabs.filter(tab => {
const isValid = isVisibleMainTab(tab);
if (!isValid) {
console.warn(
`[Navigation] Invalid tab "${tab}" in app.config.json. Skipping.`,
);
}
return isValid;
});

return { tabs, tabConfigPresent: true };
};

const MainTabNavigator: React.FC = () => {
const insets = useSafeAreaInsets();
const baseTabBarHeight = 60;
Expand All @@ -49,6 +112,9 @@ const MainTabNavigator: React.FC = () => {
// Theme colors come from AppThemeContext — they update automatically
// when the custom app's config is loaded or the color scheme changes.
const { themeColors } = useAppTheme();
const { tabs: configuredTabs, tabConfigPresent } = getConfiguredTabs();
const hideTabBar = tabConfigPresent && configuredTabs.length === 0;
const tabsToRender = hideTabBar ? (['Home'] as const) : configuredTabs;

return (
<Tab.Navigator
Expand All @@ -57,6 +123,7 @@ const MainTabNavigator: React.FC = () => {
tabBarActiveTintColor: themeColors.primary,
tabBarInactiveTintColor: colors.neutral[500],
tabBarStyle: {
display: hideTabBar ? 'none' : 'flex',
backgroundColor: themeColors.surface,
borderTopWidth: 1,
borderTopColor: themeColors.divider,
Expand All @@ -65,34 +132,33 @@ const MainTabNavigator: React.FC = () => {
height: tabBarHeight,
},
}}>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarIcon: renderHomeIcon,
}}
/>
<Tab.Screen
name="Forms"
component={FormsScreen}
options={{
tabBarIcon: renderFormsIcon,
}}
/>
<Tab.Screen
name="Observations"
component={ObservationsScreen}
options={{
tabBarIcon: renderObservationsIcon,
}}
/>
<Tab.Screen
name="Sync"
component={SyncScreen}
options={{
tabBarIcon: renderSyncIcon,
}}
/>
{tabsToRender.map(tabName => (
<Tab.Screen
key={tabName}
name={tabName}
component={TAB_COMPONENTS[tabName]}
options={{
tabBarIcon: TAB_ICONS[tabName],
}}
listeners={
tabName === 'More'
? ({ navigation }) => ({
tabPress: () => {
const state = navigation.getState();
const currentRoute = state.routes[state.index];
if (currentRoute?.name === 'More') {
(
navigation as { setParams: (params: object) => void }
).setParams({
openDrawer: Date.now(),
});
}
},
})
: undefined
}
/>
))}
<Tab.Screen
name="Settings"
component={SettingsScreen}
Expand All @@ -114,22 +180,6 @@ const MainTabNavigator: React.FC = () => {
tabBarButton: () => null,
}}
/>
<Tab.Screen
name="More"
component={MoreScreen}
options={{
tabBarIcon: renderMoreIcon,
}}
listeners={({ navigation }) => ({
tabPress: () => {
const state = navigation.getState();
const currentRoute = state.routes[state.index];
if (currentRoute?.name === 'More') {
navigation.setParams({ openDrawer: Date.now() });
}
},
})}
/>
</Tab.Navigator>
);
};
Expand Down
21 changes: 21 additions & 0 deletions formulus/src/types/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* (tab bar, headers, modals) and to forward theme colors to the Formplayer
* WebView so that forms match the custom app's look and feel.
*/
import { VisibleMainTab } from './NavigationTypes';

/**
* Color tokens for a single mode (light or dark).
Expand Down Expand Up @@ -72,6 +73,17 @@ export interface AppTheme {
dark: ThemeColors;
}

/**
* Native navigation configuration for the Formulus tab bar.
*/
export interface NavigationConfig {
/**
* Visible native tabs in display order.
* Accepts string values from app.config.json and is validated at runtime.
*/
tabs: string[];
}

/**
* Root shape of app.config.json.
*/
Expand All @@ -84,4 +96,13 @@ export interface AppConfig {
version: string;
/** Theme definition with light and dark palettes */
theme: AppTheme;
/**
* Optional native navigation settings.
* If omitted, Formulus shows all default native tabs.
*/
navigation?: NavigationConfig;
}

// Keep this alias exported so app-config consumers can strongly type
// validated tab selections after runtime filtering.
export type { VisibleMainTab };
10 changes: 10 additions & 0 deletions formulus/src/types/NavigationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ export type MainTabParamList = {
More: { openDrawer?: number } | undefined;
};

export const VISIBLE_MAIN_TABS = [
'Home',
'Forms',
'Observations',
'Sync',
'More',
] as const;

export type VisibleMainTab = (typeof VISIBLE_MAIN_TABS)[number];

export type MainAppStackParamList = {
Welcome: undefined;
MainApp: undefined;
Expand Down
Loading