mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor(native): extract NativeTabBar component and add Android-specific tab bar implementation
This commit is contained in:
@@ -1,56 +1,5 @@
|
|||||||
import { Stack } from "expo-router";
|
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
return <NativeTabBar />;
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
|
import { ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
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 { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
|
||||||
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
|
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
hasStoredServerUrl,
|
hasStoredServerUrl,
|
||||||
onServerUrlChange,
|
onServerUrlChange,
|
||||||
} from "@/lib/server-url";
|
} from "@/lib/server-url";
|
||||||
|
import { sofaTheme } from "@/lib/theme";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -191,7 +192,7 @@ function AppContent() {
|
|||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ThemeProvider value={sofaTheme}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
<ServerUnreachableBanner />
|
<ServerUnreachableBanner />
|
||||||
@@ -236,7 +237,7 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
</Stack>
|
</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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={SlideInUp.duration(300).springify().damping(18)}
|
entering={SlideInUp.duration(300).damping(18)}
|
||||||
exiting={SlideOutUp.duration(250)}
|
exiting={SlideOutUp.duration(250)}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
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;
|
||||||
|
}
|
||||||
@@ -1,18 +1,40 @@
|
|||||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { posthog } from "@/lib/posthog";
|
import { posthog } from "@/lib/posthog";
|
||||||
|
import { getIsReachable } from "@/lib/server-reachability";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
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({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
gcTime: ONE_DAY_MS,
|
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({
|
queryCache: new QueryCache({
|
||||||
onError: (error) => {
|
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", {
|
toast.error("Something went wrong\u2026", {
|
||||||
description: error.message,
|
description: error.message,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user