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 { 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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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