refactor(native): extract NativeTabBar component and add Android-specific tab bar implementation

This commit is contained in:
2026-03-15 13:25:02 -04:00
parent a2862e5948
commit d885fc524b
8 changed files with 194 additions and 57 deletions
+2 -53
View File
@@ -1,56 +1,5 @@
import { Stack } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { useCSSVariable } from "uniwind";
import * as Haptics from "@/utils/haptics";
const tabTitles: Record<string, string> = {
"(home)": "Home",
"(explore)": "Explore",
"(search)": "Search",
"(settings)": "Settings",
};
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
export default function TabLayout() {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const [activeTitle, setActiveTitle] = useState("Home");
return (
<>
<Stack.Screen options={{ title: activeTitle }} />
<NativeTabs
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
labelStyle={{
default: { color: mutedFgColor },
selected: { color: primaryColor },
}}
screenListeners={({ route }) => ({
tabPress: () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
setActiveTitle(tabTitles[route.name] ?? "Home");
},
})}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
</NativeTabs>
</>
);
return <NativeTabBar />;
}
+4 -3
View File
@@ -1,4 +1,5 @@
import "@/global.css";
import { ThemeProvider } from "@react-navigation/native";
import { QueryClientProvider } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
@@ -14,7 +15,6 @@ import { useEffect, useRef, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
@@ -38,6 +38,7 @@ import {
hasStoredServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
import { sofaTheme } from "@/lib/theme";
import { toast } from "@/lib/toast";
SplashScreen.preventAutoHideAsync();
@@ -191,7 +192,7 @@ function AppContent() {
}, [session]);
return (
<>
<ThemeProvider value={sofaTheme}>
<StatusBar style="light" />
<OfflineBanner />
<ServerUnreachableBanner />
@@ -236,7 +237,7 @@ function AppContent() {
/>
</Stack.Protected>
</Stack>
</>
</ThemeProvider>
);
}
@@ -0,0 +1,66 @@
import {
NativeTabs,
type NativeTabsProps,
} from "expo-router/unstable-native-tabs";
import { useMemo } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import * as Haptics from "@/utils/haptics";
export function NativeTabBar() {
const primaryColor = useCSSVariable("--color-primary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const surfaceColor = useCSSVariable("--color-card") as string;
const rippleColor = useCSSVariable("--color-secondary") as string;
const screenListeners = useMemo<NativeTabsProps["screenListeners"]>(
() => ({
tabPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
}),
[],
);
const labelTextStyle = useResolveClassNames("font-sans-medium text-[11.5px]");
return (
<NativeTabs
backgroundColor={surfaceColor}
disableIndicator
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
indicatorColor="transparent"
labelStyle={{
default: [
labelTextStyle as Record<string, unknown>,
{ color: mutedFgColor },
],
selected: [
labelTextStyle as Record<string, unknown>,
{ color: primaryColor },
],
}}
labelVisibilityMode="labeled"
rippleColor={rippleColor}
screenListeners={screenListeners}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="search" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
@@ -0,0 +1,56 @@
import * as Haptics from "expo-haptics";
import { Stack } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { useCSSVariable } from "uniwind";
const tabTitles: Record<string, string> = {
"(home)": "Home",
"(explore)": "Explore",
"(search)": "Search",
"(settings)": "Settings",
};
export function NativeTabBar() {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const [activeTitle, setActiveTitle] = useState("Home");
return (
<>
<Stack.Screen options={{ title: activeTitle }} />
<NativeTabs
iconColor={{
default: mutedFgColor,
selected: primaryColor,
}}
labelStyle={{
default: { color: mutedFgColor },
selected: { color: primaryColor },
}}
screenListeners={({ route }) => ({
tabPress: () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
focus: () => {
setActiveTitle(tabTitles[route.name] ?? "Home");
},
})}
>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
</NativeTabs>
</>
);
}
@@ -45,7 +45,7 @@ export function ServerUnreachableBanner() {
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
entering={SlideInUp.duration(300).damping(18)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
@@ -0,0 +1,29 @@
import type { Stack } from "expo-router";
import type { ComponentProps } from "react";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { HeaderAvatar } from "@/components/header-avatar";
type ScreenOptions = NonNullable<
Extract<ComponentProps<typeof Stack>["screenOptions"], object>
>;
export function useTabScreenOptions() {
const backgroundColor = useCSSVariable("--color-background") as string;
const headerTitleStyle = useResolveClassNames(
"font-sans-semibold text-lg text-foreground",
);
const contentStyle = useResolveClassNames("bg-background");
return {
headerLargeTitle: false,
headerTransparent: false,
headerStyle: { backgroundColor },
headerTitleAlign: "left" as const,
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: useCSSVariable("--color-primary") as string,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal" as const,
headerRight: () => <HeaderAvatar />,
contentStyle,
} satisfies ScreenOptions;
}
+22
View File
@@ -1,18 +1,40 @@
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { posthog } from "@/lib/posthog";
import { getIsReachable } from "@/lib/server-reachability";
import { toast } from "@/lib/toast";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
function isNetworkError(error: Error): boolean {
const msg = error.message.toLowerCase();
return (
msg.includes("network request failed") ||
msg.includes("fetch failed") ||
msg.includes("unable to resolve host") ||
msg.includes("could not connect") ||
error.name === "TypeError"
);
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: ONE_DAY_MS,
retry: (failureCount, error) => {
// Don't retry network errors when server is unreachable — the
// banner already tells the user and retries will just spam toasts.
if (!getIsReachable() && isNetworkError(error)) return false;
return failureCount < 3;
},
},
},
queryCache: new QueryCache({
onError: (error) => {
// Suppress toasts for network errors when the server-unreachable
// banner is already visible — avoids flooding native toasts.
if (!getIsReachable() && isNetworkError(error)) return;
toast.error("Something went wrong\u2026", {
description: error.message,
});
+14
View File
@@ -0,0 +1,14 @@
import { DarkTheme } from "@react-navigation/native";
export const sofaTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: "#090706", // --color-background oklch(0.13 0.006 55)
card: "#171310", // --color-card oklch(0.19 0.008 55)
text: "#ede7dd", // --color-foreground oklch(0.93 0.015 80)
border: "#282320", // --color-border approximate
primary: "#fba952", // --color-primary oklch(0.8 0.14 65)
notification: "#fba952", // --color-primary
},
};