refactor(native): polish UI, add accessibility labels, and improve auth navigation

Add `accessibilityLabel` (and `accessibilityHint`) to all form inputs across auth and settings screens. Switch the auth stack from a hidden header to a transparent header with back-button support so deep-linked screens navigate correctly. Present the change-password screen as a form sheet on iOS and a modal on Android, and wrap its body in a `ScrollView` to handle the keyboard and small screens. Move inline `contentContainerStyle` objects in the explore and home screens to module-level constants to avoid unnecessary re-renders. Enable `EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH` and `EXPO_UNSTABLE_TREE_SHAKING` in EAS production builds. Also apply miscellaneous layout and styling fixes across `poster-card`, `switch`, `text-field`, `hero-banner`, and several other UI components.
This commit is contained in:
2026-03-16 00:15:35 -04:00
parent 7995790864
commit 8dfa54a60c
42 changed files with 1230 additions and 792 deletions
+5 -1
View File
@@ -5,7 +5,11 @@
}, },
"build": { "build": {
"production": { "production": {
"autoIncrement": true "autoIncrement": true,
"env": {
"EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH": "1",
"EXPO_UNSTABLE_TREE_SHAKING": "1"
}
}, },
"development": { "development": {
"developmentClient": true, "developmentClient": true,
+10 -2
View File
@@ -1,5 +1,5 @@
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useResolveClassNames } from "uniwind"; import { useCSSVariable, useResolveClassNames } from "uniwind";
import { hasStoredServerUrl } from "@/lib/server-url"; import { hasStoredServerUrl } from "@/lib/server-url";
export const unstable_settings = { export const unstable_settings = {
@@ -10,12 +10,20 @@ export const unstable_settings = {
}; };
export default function AuthLayout() { export default function AuthLayout() {
const headerTitleStyle = useResolveClassNames(
"font-display text-base text-foreground",
);
const contentStyle = useResolveClassNames("bg-background"); const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerTransparent: true,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
headerTitleStyle: headerTitleStyle as Record<string, unknown>,
headerTintColor: tintColor,
contentStyle, contentStyle,
animation: "fade", animation: "fade",
}} }}
+4 -1
View File
@@ -1,6 +1,6 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router"; import { Link, Stack } from "expo-router";
import { useRef } from "react"; import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native"; import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -73,6 +73,7 @@ export default function LoginScreen() {
return ( return (
<AuthScreen title="Sofa" subtitle="Sign in to continue"> <AuthScreen title="Sofa" subtitle="Sign in to continue">
<Stack.Screen options={{ title: "Sign In" }} />
{showOidc && ( {showOidc && (
<Animated.View <Animated.View
entering={FadeInDown.duration(300).delay(100)} entering={FadeInDown.duration(300).delay(100)}
@@ -125,6 +126,7 @@ export default function LoginScreen() {
<Label>Email</Label> <Label>Email</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Email"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="email@example.com" placeholder="email@example.com"
@@ -149,6 +151,7 @@ export default function LoginScreen() {
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Password"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
+6 -1
View File
@@ -1,6 +1,6 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router"; import { Link, Stack } from "expo-router";
import { useRef } from "react"; import { useRef } from "react";
import { Alert, Pressable, type TextInput, View } from "react-native"; import { Alert, Pressable, type TextInput, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
@@ -90,6 +90,7 @@ export default function RegisterScreen() {
title="Registration Closed" title="Registration Closed"
subtitle="New account creation is currently disabled." subtitle="New account creation is currently disabled."
> >
<Stack.Screen options={{ title: "Registration Closed" }} />
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<Link href="/(auth)/login" asChild> <Link href="/(auth)/login" asChild>
<Button className="mt-6 bg-primary"> <Button className="mt-6 bg-primary">
@@ -105,6 +106,7 @@ export default function RegisterScreen() {
return ( return (
<AuthScreen title="Create Account"> <AuthScreen title="Create Account">
<Stack.Screen options={{ title: "Create Account" }} />
<form.Subscribe <form.Subscribe
selector={(state) => ({ selector={(state) => ({
isSubmitting: state.isSubmitting, isSubmitting: state.isSubmitting,
@@ -126,6 +128,7 @@ export default function RegisterScreen() {
<Label>Name</Label> <Label>Name</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Name"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="Your name" placeholder="Your name"
@@ -148,6 +151,7 @@ export default function RegisterScreen() {
<Input <Input
ref={emailRef} ref={emailRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Email"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="email@example.com" placeholder="email@example.com"
@@ -172,6 +176,7 @@ export default function RegisterScreen() {
<Input <Input
ref={passwordRef} ref={passwordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Password"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
+9 -6
View File
@@ -3,7 +3,7 @@ import {
IconCircleCheck, IconCircleCheck,
IconInfoCircle, IconInfoCircle,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Linking, Pressable, TextInput, View } from "react-native"; import { Linking, Pressable, TextInput, View } from "react-native";
import Animated, { import Animated, {
@@ -151,14 +151,17 @@ export default function ServerUrlScreen() {
subtitle="Enter your Sofa server URL to get started" subtitle="Enter your Sofa server URL to get started"
logoStyle={iconAnimatedStyle} logoStyle={iconAnimatedStyle}
> >
<Stack.Screen options={{ title: "Server" }} />
<Animated.View entering={FadeInDown.duration(300).delay(200)}> <Animated.View entering={FadeInDown.duration(300).delay(200)}>
<View <View
className="h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5" className="min-h-12 flex-row items-center rounded-[12px] border border-border bg-input px-3.5"
style={{ borderCurve: "continuous" }} style={{ borderCurve: "continuous" }}
> >
<TextInput <TextInput
ref={inputRef} ref={inputRef}
value={url} value={url}
accessibilityLabel="Server URL"
accessibilityHint="Enter the full URL for your Sofa server"
onChangeText={handleChangeText} onChangeText={handleChangeText}
placeholder="https://sofa.example.com" placeholder="https://sofa.example.com"
placeholderTextColorClassName="accent-muted-foreground/50" placeholderTextColorClassName="accent-muted-foreground/50"
@@ -169,7 +172,7 @@ export default function ServerUrlScreen() {
returnKeyType="go" returnKeyType="go"
editable={!isDisabled} editable={!isDisabled}
onSubmitEditing={handleConnect} onSubmitEditing={handleConnect}
className="flex-1 py-0 font-mono text-[15px] text-foreground" className="flex-1 py-3 font-mono text-[15px] text-foreground"
/> />
</View> </View>
</Animated.View> </Animated.View>
@@ -180,7 +183,7 @@ export default function ServerUrlScreen() {
className="mt-4" className="mt-4"
> >
{isConnecting ? ( {isConnecting ? (
<View className="h-12 flex-row items-center justify-center gap-2"> <View className="min-h-12 flex-row items-center justify-center gap-2 py-2">
<Animated.View <Animated.View
className="size-1.5 rounded-full bg-primary" className="size-1.5 rounded-full bg-primary"
style={dotAnimatedStyle} style={dotAnimatedStyle}
@@ -190,7 +193,7 @@ export default function ServerUrlScreen() {
</Text> </Text>
</View> </View>
) : isSuccess ? ( ) : isSuccess ? (
<View className="h-12 flex-row items-center justify-center gap-1.5"> <View className="min-h-12 flex-row items-center justify-center gap-1.5 py-2">
<IconCircleCheck size={16} color={statusCompletedColor} /> <IconCircleCheck size={16} color={statusCompletedColor} />
<Text className="font-sans-medium text-[13px] text-status-completed"> <Text className="font-sans-medium text-[13px] text-status-completed">
Connected Connected
@@ -218,7 +221,7 @@ export default function ServerUrlScreen() {
color={destructiveColor} color={destructiveColor}
style={{ marginTop: 1 }} style={{ marginTop: 1 }}
/> />
<Text className="flex-1 text-[13px] text-destructive"> <Text selectable className="flex-1 text-[13px] text-destructive">
{ERROR_MESSAGES[connection.error]} {ERROR_MESSAGES[connection.error]}
</Text> </Text>
</Animated.View> </Animated.View>
@@ -10,6 +10,11 @@ import { HeroBanner } from "@/components/explore/hero-banner";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
const exploreContentContainerStyle = {
paddingTop: 8,
paddingBottom: 16,
};
export default function ExploreScreen() { export default function ExploreScreen() {
const trending = useInfiniteQuery( const trending = useInfiniteQuery(
orpc.explore.trending.infiniteOptions({ orpc.explore.trending.infiniteOptions({
@@ -68,10 +73,7 @@ export default function ExploreScreen() {
return ( return (
<ScrollView <ScrollView
className="bg-background" className="bg-background"
contentContainerStyle={{ contentContainerStyle={exploreContentContainerStyle}
paddingTop: 8,
paddingBottom: 16,
}}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
+5 -4
View File
@@ -22,6 +22,10 @@ import { queryClient } from "@/lib/query-client";
const horizontalListStyle = { overflow: "visible" as const }; const horizontalListStyle = { overflow: "visible" as const };
const statsListContentStyle = { gap: 12, paddingHorizontal: 16 }; const statsListContentStyle = { gap: 12, paddingHorizontal: 16 };
const continueWatchingContentStyle = { gap: 12, paddingHorizontal: 16 }; const continueWatchingContentStyle = { gap: 12, paddingHorizontal: 16 };
const dashboardContentContainerStyle = {
paddingTop: 8,
paddingBottom: 16,
};
export default function DashboardScreen() { export default function DashboardScreen() {
const { push } = useRouter(); const { push } = useRouter();
@@ -75,10 +79,7 @@ export default function DashboardScreen() {
return ( return (
<ScrollView <ScrollView
className="bg-background" className="bg-background"
contentContainerStyle={{ contentContainerStyle={dashboardContentContainerStyle}
paddingTop: 8,
paddingBottom: 16,
}}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -2,5 +2,29 @@ import { Stack } from "expo-router";
import { useTabScreenOptions } from "@/hooks/use-tab-screen-options"; import { useTabScreenOptions } from "@/hooks/use-tab-screen-options";
export default function SettingsLayout() { export default function SettingsLayout() {
return <Stack screenOptions={useTabScreenOptions()} />; const screenOptions = useTabScreenOptions();
const changePasswordOptions =
process.env.EXPO_OS === "ios"
? {
presentation: "formSheet" as const,
sheetAllowedDetents: "fitToContents" as const,
sheetGrabberVisible: true,
headerLargeTitle: false,
headerTransparent: false,
headerBlurEffect: "none" as const,
unstable_headerRightItems: () => [],
}
: {
presentation: "modal" as const,
headerLargeTitle: false,
headerTransparent: false,
headerBlurEffect: "none" as const,
unstable_headerRightItems: () => [],
};
return (
<Stack screenOptions={screenOptions}>
<Stack.Screen name="change-password" options={changePasswordOptions} />
</Stack>
);
} }
@@ -1,7 +1,7 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Alert, type TextInput, View } from "react-native"; import { Alert, ScrollView, type TextInput, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { z } from "zod"; import { z } from "zod";
import { Button, ButtonLabel } from "@/components/ui/button"; import { Button, ButtonLabel } from "@/components/ui/button";
@@ -82,7 +82,16 @@ export default function ChangePasswordScreen() {
}); });
return ( return (
<View className="flex-1 bg-background px-4 pt-4"> <ScrollView
className="flex-1 bg-background"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 24,
}}
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
<Stack.Screen options={{ title: "Change Password" }} /> <Stack.Screen options={{ title: "Change Password" }} />
<form.Subscribe <form.Subscribe
@@ -106,6 +115,7 @@ export default function ChangePasswordScreen() {
<Label>Current password</Label> <Label>Current password</Label>
<Input <Input
value={field.state.value} value={field.state.value}
accessibilityLabel="Current password"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -129,6 +139,7 @@ export default function ChangePasswordScreen() {
<Input <Input
ref={newPasswordRef} ref={newPasswordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="New password"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -154,6 +165,7 @@ export default function ChangePasswordScreen() {
<Input <Input
ref={confirmPasswordRef} ref={confirmPasswordRef}
value={field.state.value} value={field.state.value}
accessibilityLabel="Confirm new password"
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChangeText={field.handleChange} onChangeText={field.handleChange}
placeholder="••••••••" placeholder="••••••••"
@@ -179,6 +191,7 @@ export default function ChangePasswordScreen() {
<Switch <Switch
value={revokeOtherSessions} value={revokeOtherSessions}
onValueChange={setRevokeOtherSessions} onValueChange={setRevokeOtherSessions}
accessibilityLabel="Sign out of other sessions"
/> />
</Animated.View> </Animated.View>
@@ -198,6 +211,6 @@ export default function ChangePasswordScreen() {
</View> </View>
)} )}
</form.Subscribe> </form.Subscribe>
</View> </ScrollView>
); );
} }
+28 -10
View File
@@ -49,6 +49,12 @@ import { queryClient } from "@/lib/query-client";
import { getServerUrl } from "@/lib/server-url"; import { getServerUrl } from "@/lib/server-url";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
const settingsContentContainerStyle = {
paddingTop: 8,
paddingBottom: 32,
paddingHorizontal: 16,
};
export default function SettingsScreen() { export default function SettingsScreen() {
const { push } = useRouter(); const { push } = useRouter();
const { data: session, refetch: refetchSession } = authClient.useSession(); const { data: session, refetch: refetchSession } = authClient.useSession();
@@ -56,8 +62,8 @@ export default function SettingsScreen() {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
useEffect(() => { useEffect(() => {
if (session?.user?.name) setNameInput(session.user.name); if (!isEditingName && session?.user?.name) setNameInput(session.user.name);
}, [session?.user?.name]); }, [session?.user?.name, isEditingName]);
const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled); const [analyticsEnabled, setAnalyticsToggle] = useState(isAnalyticsEnabled);
const isAdmin = session?.user?.role === "admin"; const isAdmin = session?.user?.role === "admin";
@@ -201,11 +207,7 @@ export default function SettingsScreen() {
return ( return (
<ScrollView <ScrollView
className="bg-background" className="bg-background"
contentContainerStyle={{ contentContainerStyle={settingsContentContainerStyle}
paddingTop: 8,
paddingBottom: 32,
paddingHorizontal: 16,
}}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -224,7 +226,13 @@ export default function SettingsScreen() {
{hasAvatarImage ? ( {hasAvatarImage ? (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable className="mr-3"> <Pressable
accessibilityRole="button"
accessibilityLabel="Edit profile photo"
accessibilityHint="Opens options to change or remove your photo"
className="mr-3"
hitSlop={8}
>
<View className="size-11 overflow-hidden rounded-full bg-secondary"> <View className="size-11 overflow-hidden rounded-full bg-secondary">
{uploadAvatar.isPending ? ( {uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
@@ -265,7 +273,13 @@ export default function SettingsScreen() {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
) : ( ) : (
<Pressable onPress={pickAvatar} className="mr-3"> <Pressable
onPress={pickAvatar}
accessibilityRole="button"
accessibilityLabel="Add profile photo"
className="mr-3"
hitSlop={8}
>
<View className="size-11 overflow-hidden rounded-full bg-secondary"> <View className="size-11 overflow-hidden rounded-full bg-secondary">
{uploadAvatar.isPending ? ( {uploadAvatar.isPending ? (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
@@ -289,8 +303,9 @@ export default function SettingsScreen() {
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<TextInput <TextInput
value={nameInput} value={nameInput}
accessibilityLabel="Display name"
onChangeText={setNameInput} onChangeText={setNameInput}
className="flex-1 border-primary border-b py-0.5 font-sans text-[15px] text-foreground" className="min-h-10 flex-1 border-primary border-b py-2 font-sans text-[15px] text-foreground"
autoFocus autoFocus
/> />
<Pressable <Pressable
@@ -381,6 +396,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={analyticsEnabled} value={analyticsEnabled}
accessibilityLabel="Anonymous usage reporting"
onValueChange={(enabled) => { onValueChange={(enabled) => {
setAnalyticsToggle(enabled); setAnalyticsToggle(enabled);
setAnalyticsEnabled(enabled); setAnalyticsEnabled(enabled);
@@ -449,6 +465,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={registration.data?.open ?? false} value={registration.data?.open ?? false}
accessibilityLabel="Open registration"
onValueChange={(open) => toggleRegistration.mutate({ open })} onValueChange={(open) => toggleRegistration.mutate({ open })}
/> />
} }
@@ -459,6 +476,7 @@ export default function SettingsScreen() {
right={ right={
<Switch <Switch
value={updateCheck.data?.enabled ?? false} value={updateCheck.data?.enabled ?? false}
accessibilityLabel="Check for updates"
onValueChange={(enabled) => onValueChange={(enabled) =>
toggleUpdateCheck.mutate({ enabled }) toggleUpdateCheck.mutate({ enabled })
} }
-2
View File
@@ -218,7 +218,6 @@ function AppContent() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
animation: "slide_from_right", animation: "slide_from_right",
}} }}
/> />
@@ -231,7 +230,6 @@ function AppContent() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
animation: "slide_from_right", animation: "slide_from_right",
}} }}
/> />
+123 -67
View File
@@ -1,3 +1,4 @@
import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -7,7 +8,8 @@ import {
IconUser, IconUser,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { format, parseISO } from "date-fns"; import { format } from "date-fns/format";
import { parseISO } from "date-fns/parseISO";
import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { import {
@@ -54,15 +56,18 @@ const departmentLabels: Record<string, string> = {
export default function PersonDetailScreen() { export default function PersonDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight();
const { back } = useRouter(); const { back } = useRouter();
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
const useAutomaticInsets = process.env.EXPO_OS === "ios";
const columnWidth = const columnWidth =
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2; (screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const { handlePress, handleQuickAdd, addingKey } = usePosterActions(); const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const { const {
data, data,
@@ -127,82 +132,131 @@ export default function PersonDetailScreen() {
onPress={handlePress} onPress={handlePress}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`} isAdding={addingKey === `${credit.tmdbId}-${credit.type}`}
failedKey={failedKey}
onQuickAddFailed={resetError}
/> />
</View> </View>
), ),
[columnWidth, userStatuses, handlePress, handleQuickAdd, addingKey], [
columnWidth,
userStatuses,
handlePress,
handleQuickAdd,
addingKey,
failedKey,
resetError,
],
); );
if (isPending) { if (isPending) {
return ( return (
<View <>
className="flex-1 items-center bg-background" <Stack.Screen
style={{ paddingTop: insets.top + 56 }} options={{
> title: "Person",
{/* Profile photo skeleton */} headerTransparent: true,
<Skeleton width={120} height={120} borderRadius={60} /> headerBlurEffect: "none",
{/* Name skeleton */} headerTintColor: "white",
<Skeleton headerBackButtonDisplayMode: "minimal",
width={180} }}
height={28}
borderRadius={6}
style={{ marginTop: 16 }}
/> />
{/* Department badge skeleton */} <View
<Skeleton className="flex-1 items-center bg-background"
width={80} style={{ paddingTop: headerHeight + 24 }}
height={24} >
borderRadius={12} {/* Profile photo skeleton */}
style={{ marginTop: 8 }} <Skeleton width={120} height={120} borderRadius={60} />
/> {/* Name skeleton */}
{/* Bio skeleton */} <Skeleton
<View className="mt-6 gap-2 self-stretch px-4"> width={180}
<Skeleton width="100%" height={14} /> height={28}
<Skeleton width="100%" height={14} /> borderRadius={6}
<Skeleton width="60%" height={14} /> style={{ marginTop: 16 }}
/>
{/* Department badge skeleton */}
<Skeleton
width={80}
height={24}
borderRadius={12}
style={{ marginTop: 8 }}
/>
{/* Bio skeleton */}
<View className="mt-6 gap-2 self-stretch px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="60%" height={14} />
</View>
</View> </View>
</View> </>
); );
} }
if (isError && !data) { if (isError && !data) {
return ( return (
<View <>
className="flex-1 items-center justify-center bg-background" <Stack.Screen
style={{ paddingTop: insets.top }} options={{
> title: "Person",
<Animated.View entering={FadeIn.duration(400)} className="items-center"> headerTransparent: true,
<IconAlertTriangle size={48} color={mutedForeground} /> headerBlurEffect: "none",
<Text className="mt-3 font-display text-foreground text-xl"> headerTintColor: "white",
Something went wrong headerBackButtonDisplayMode: "minimal",
</Text> }}
<Text className="mt-1 text-center text-muted-foreground text-sm"> />
Could not load person details <View
</Text> className="flex-1 items-center justify-center bg-background"
<Pressable onPress={() => back()} className="mt-4"> style={{ paddingTop: insets.top }}
<Text className="text-primary">Go back</Text> >
</Pressable> <Animated.View
</Animated.View> entering={FadeIn.duration(400)}
</View> className="items-center"
>
<IconAlertTriangle size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Something went wrong
</Text>
<Text className="mt-1 text-center text-muted-foreground text-sm">
Could not load person details
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text>
</Pressable>
</Animated.View>
</View>
</>
); );
} }
if (!person) { if (!person) {
return ( return (
<View <>
className="flex-1 items-center justify-center bg-background" <Stack.Screen
style={{ paddingTop: insets.top }} options={{
> title: "Person",
<Animated.View entering={FadeIn.duration(400)} className="items-center"> headerTransparent: true,
<IconUser size={48} color={mutedForeground} /> headerBlurEffect: "none",
<Text className="mt-3 font-display text-foreground text-xl"> headerTintColor: "white",
Person not found headerBackButtonDisplayMode: "minimal",
</Text> }}
<Pressable onPress={() => back()} className="mt-4"> />
<Text className="text-primary">Go back</Text> <View
</Pressable> className="flex-1 items-center justify-center bg-background"
</Animated.View> style={{ paddingTop: insets.top }}
</View> >
<Animated.View
entering={FadeIn.duration(400)}
className="items-center"
>
<IconUser size={48} color={mutedForeground} />
<Text className="mt-3 font-display text-foreground text-xl">
Person not found
</Text>
<Pressable onPress={() => back()} className="mt-4">
<Text className="text-primary">Go back</Text>
</Pressable>
</Animated.View>
</View>
</>
); );
} }
@@ -212,7 +266,10 @@ export default function PersonDetailScreen() {
<Animated.View <Animated.View
entering={FadeIn.duration(400)} entering={FadeIn.duration(400)}
className="items-center" className="items-center"
style={{ paddingTop: insets.top + 56, paddingBottom: 24 }} style={{
paddingTop: useAutomaticInsets ? 16 : insets.top + 56,
paddingBottom: 24,
}}
> >
<View className="size-[120px] overflow-hidden rounded-full bg-secondary"> <View className="size-[120px] overflow-hidden rounded-full bg-secondary">
{person.profilePath && ( {person.profilePath && (
@@ -225,10 +282,7 @@ export default function PersonDetailScreen() {
)} )}
</View> </View>
<Text <Text className="mt-4 text-center font-display text-[28px] text-foreground">
selectable
className="mt-4 text-center font-display text-[28px] text-foreground"
>
{person.name} {person.name}
</Text> </Text>
@@ -294,12 +348,11 @@ export default function PersonDetailScreen() {
<> <>
<Stack.Screen <Stack.Screen
options={{ options={{
title: person?.name ?? "", title: person.name,
headerTransparent: true, headerTransparent: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}} }}
/> />
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
@@ -308,8 +361,11 @@ export default function PersonDetailScreen() {
keyExtractor={(item) => item.titleId} keyExtractor={(item) => item.titleId}
renderItem={renderFilmographyItem} renderItem={renderFilmographyItem}
numColumns={2} numColumns={2}
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: insets.bottom + 32, paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
paddingHorizontal: FILMOGRAPHY_PADDING, paddingHorizontal: FILMOGRAPHY_PADDING,
}} }}
ListHeaderComponent={listHeader} ListHeaderComponent={listHeader}
+423 -378
View File
@@ -1,3 +1,4 @@
import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
IconBrandAppstore, IconBrandAppstore,
@@ -16,7 +17,7 @@ import { LinearGradient } from "expo-linear-gradient";
import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
@@ -43,11 +44,50 @@ import { toast } from "@/lib/toast";
const castListContentStyle = { gap: 12, paddingHorizontal: 16 }; const castListContentStyle = { gap: 12, paddingHorizontal: 16 };
const castListStyle = { overflow: "visible" as const }; const castListStyle = { overflow: "visible" as const };
const titleGenresContentStyle = { paddingHorizontal: 16 };
const titleAvailabilityContentStyle = { gap: 8, paddingHorizontal: 16 };
const titleDetailStyles = StyleSheet.create({
heroFill: {
...StyleSheet.absoluteFillObject,
},
heroBaseOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.1)",
},
heroGradient: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
},
trailerGlass: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: "center",
justifyContent: "center",
},
trailerFallback: {
backgroundColor: "rgba(0,0,0,0.6)",
},
posterImage: {
width: "100%",
height: "100%",
},
providerLogo: {
width: 44,
height: 44,
borderRadius: 10,
},
});
export default function TitleDetailScreen() { export default function TitleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight();
const { back } = useRouter(); const { back } = useRouter();
const useAutomaticInsets = process.env.EXPO_OS === "ios";
const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([ const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([
"--color-title-accent", "--color-title-accent",
@@ -134,6 +174,8 @@ export default function TitleDetailScreen() {
const title = detail.data?.title; const title = detail.data?.title;
const palette = title?.colorPalette ?? null; const palette = title?.colorPalette ?? null;
useTitleTheme(palette); useTitleTheme(palette);
const providerIcon =
process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
const titleName = title?.title; const titleName = title?.title;
const titleType = title?.type; const titleType = title?.type;
@@ -185,6 +227,45 @@ export default function TitleDetailScreen() {
); );
const hydratedTitleId = useRef<string | null>(null); const hydratedTitleId = useRef<string | null>(null);
const hydrateSeasonsRef = useRef(hydrateMutation.mutate);
useEffect(() => {
hydrateSeasonsRef.current = hydrateMutation.mutate;
}, [hydrateMutation.mutate]);
const titleScrollContentStyle = useMemo(
() => ({
paddingBottom: useAutomaticInsets ? 32 : insets.bottom + 32,
}),
[useAutomaticInsets, insets.bottom],
);
const heroMarginStyle = useMemo(
() => (useAutomaticInsets ? { marginTop: -headerHeight } : undefined),
[useAutomaticInsets, headerHeight],
);
const darkMutedOverlayStyle = useMemo(
() =>
palette?.darkMuted
? { backgroundColor: palette.darkMuted, opacity: 0.2 }
: undefined,
[palette?.darkMuted],
);
const vibrantOverlayStyle = useMemo(
() =>
palette?.vibrant
? { backgroundColor: palette.vibrant, opacity: 0.06 }
: undefined,
[palette?.vibrant],
);
const posterShadowStyle = useMemo(
() =>
palette?.darkVibrant
? {
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`,
}
: undefined,
[palette?.darkVibrant],
);
useEffect(() => { useEffect(() => {
if ( if (
detail.data?.needsHydration && detail.data?.needsHydration &&
@@ -192,447 +273,411 @@ export default function TitleDetailScreen() {
hydratedTitleId.current !== id hydratedTitleId.current !== id
) { ) {
hydratedTitleId.current = id; hydratedTitleId.current = id;
hydrateMutation.mutate({ id, tmdbId: title.tmdbId }); hydrateSeasonsRef.current({ id, tmdbId: title.tmdbId });
} }
}, [ }, [detail.data?.needsHydration, title?.type, title?.tmdbId, id]);
detail.data?.needsHydration,
title?.type,
title?.tmdbId,
id,
hydrateMutation.mutate,
]);
if (detail.isPending) { if (detail.isPending) {
return ( return (
<View className="flex-1 bg-background"> <>
<Stack.Screen <Stack.Screen
options={{ options={{
title: "", title: "Title",
headerTransparent: true, headerTransparent: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}} }}
/> />
{/* Hero skeleton */} <View className="flex-1 bg-background">
<Skeleton width="100%" height={300} borderRadius={0} /> {/* Hero skeleton */}
{/* Genre chips skeleton */} <Skeleton width="100%" height={300} borderRadius={0} />
<View className="mt-3 flex-row gap-2 px-4"> {/* Genre chips skeleton */}
<Skeleton width={60} height={24} borderRadius={12} /> <View className="mt-3 flex-row gap-2 px-4">
<Skeleton width={80} height={24} borderRadius={12} /> <Skeleton width={60} height={24} borderRadius={12} />
<Skeleton width={50} height={24} borderRadius={12} /> <Skeleton width={80} height={24} borderRadius={12} />
<Skeleton width={50} height={24} borderRadius={12} />
</View>
{/* Actions skeleton */}
<View className="mt-4 flex-row items-center gap-3 px-4">
<Skeleton width={110} height={36} borderRadius={8} />
<Skeleton width={1} height={24} borderRadius={0} />
<Skeleton width={120} height={22} borderRadius={4} />
</View>
{/* Overview skeleton */}
<View className="mt-5 gap-2 px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="70%" height={14} />
</View>
</View> </View>
{/* Actions skeleton */} </>
<View className="mt-4 flex-row items-center gap-3 px-4">
<Skeleton width={110} height={36} borderRadius={8} />
<Skeleton width={1} height={24} borderRadius={0} />
<Skeleton width={120} height={22} borderRadius={4} />
</View>
{/* Overview skeleton */}
<View className="mt-5 gap-2 px-4">
<Skeleton width="100%" height={14} />
<Skeleton width="100%" height={14} />
<Skeleton width="70%" height={14} />
</View>
</View>
); );
} }
if (!title) { if (!title) {
return ( return (
<View className="flex-1 items-center justify-center bg-background px-6"> <>
<Stack.Screen <Stack.Screen
options={{ options={{
title: "", title: "Title",
headerTransparent: true, headerTransparent: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}} }}
/> />
<IconMovie size={48} color={mutedForeground} /> <View className="flex-1 items-center justify-center bg-background px-6">
<Text className="mt-3 font-display text-foreground text-xl"> <IconMovie size={48} color={mutedForeground} />
Title not found <Text className="mt-3 font-display text-foreground text-xl">
</Text> Title not found
<Pressable onPress={() => back()} className="mt-4"> </Text>
<Text className="text-primary">Go back</Text> <Pressable onPress={() => back()} className="mt-4">
</Pressable> <Text className="text-primary">Go back</Text>
</View> </Pressable>
</View>
</>
); );
} }
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4); const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
return ( return (
<ScrollView <>
className="bg-background"
contentContainerStyle={{ paddingBottom: insets.bottom + 32 }}
>
<Stack.Screen <Stack.Screen
options={{ options={{
title: title?.title ?? "", title: title.title,
headerTransparent: true, headerTransparent: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTintColor: "white", headerTintColor: "white",
headerBackButtonDisplayMode: "minimal", headerBackButtonDisplayMode: "minimal",
headerTitle: "",
}} }}
/> />
<ScrollView
{/* Hero */} className="bg-background"
<View className="h-[300px]"> contentInsetAdjustmentBehavior={
{title.backdropPath && ( useAutomaticInsets ? "automatic" : "never"
<Image }
source={{ uri: title.backdropPath }} contentContainerStyle={titleScrollContentStyle}
thumbHash={title.backdropThumbHash}
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
contentFit="cover"
/>
)}
{/* Base darkening overlay */}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.1)" }}
/>
{/* Colored tint from palette */}
{palette?.darkMuted && (
<View
className="absolute inset-0"
style={{ backgroundColor: palette.darkMuted, opacity: 0.2 }}
/>
)}
{palette?.vibrant && (
<View
className="absolute inset-0"
style={{ backgroundColor: palette.vibrant, opacity: 0.06 }}
/>
)}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
{title.trailerVideoKey && (
<Pressable
onPress={() =>
WebBrowser.openBrowserAsync(
`https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
)
}
className="absolute inset-0 items-center justify-center"
>
{isLiquidGlassAvailable() ? (
<GlassView
glassEffectStyle="clear"
colorScheme="dark"
isInteractive={true}
style={{
width: 56,
height: 56,
borderRadius: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</GlassView>
) : (
<View
className="h-14 w-14 items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</View>
)}
</Pressable>
)}
<View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
{title.posterPath && (
<View
className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
style={[
{ borderCurve: "continuous" },
palette?.darkVibrant
? {
boxShadow: `0 12px 28px -8px ${palette.darkVibrant}80`,
}
: undefined,
]}
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={{
width: "100%",
height: "100%",
}}
contentFit="cover"
/>
</View>
)}
<View className="flex-1 pb-1">
<Text
selectable
className="font-display text-2xl text-white"
numberOfLines={2}
>
{title.title}
</Text>
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
<View className="rounded-full bg-title-accent px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-title-accent-foreground">
{title.type === "movie" ? "Movie" : "TV"}
</Text>
</View>
{year ? (
<Text className="text-[13px] text-white/70">{year}</Text>
) : null}
{title.contentRating ? (
<Text className="text-white/50 text-xs">
{title.contentRating}
</Text>
) : null}
{title.voteAverage != null && title.voteAverage > 0 && (
<View className="flex-row items-center gap-0.5">
<IconStarFilled size={12} color={titleAccent} />
<Text className="text-title-accent text-xs">
{title.voteAverage.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
</View>
</View>
{/* Genres */}
{title.genres && title.genres.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-3"
contentContainerStyle={{ paddingHorizontal: 16 }}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<Text className="text-[11px] text-muted-foreground">
{genre}
</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Actions */}
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
className="mt-4 px-4"
> >
<View className="flex-row flex-wrap items-center gap-3"> {/* Hero */}
<StatusActionButton <View className="h-[300px]" style={heroMarginStyle}>
currentStatus={userInfo.data?.status ?? null} {title.backdropPath && (
onStatusChange={(status) => { <Image
if (status === "watchlist") { source={{ uri: title.backdropPath }}
quickAddMutation.mutate({ thumbHash={title.backdropThumbHash}
tmdbId: title.tmdbId, style={titleDetailStyles.heroFill}
type: title.type, contentFit="cover"
}); />
} else { )}
updateStatus.mutate({ id, status: null }); {/* Base darkening overlay */}
} <View style={titleDetailStyles.heroBaseOverlay} />
}} {/* Colored tint from palette */}
isPending={ {palette?.darkMuted && (
updateStatus.isPending || <View style={[titleDetailStyles.heroFill, darkMutedOverlayStyle]} />
quickAddMutation.isPending || )}
watchMovie.isPending {palette?.vibrant && (
} <View style={[titleDetailStyles.heroFill, vibrantOverlayStyle]} />
)}
{/* Bottom fade to background */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={titleDetailStyles.heroGradient}
/> />
{title.type === "movie" && ( {title.trailerVideoKey && (
<Pressable <Pressable
onPress={() => watchMovie.mutate({ id })} onPress={() =>
disabled={watchMovie.isPending} WebBrowser.openBrowserAsync(
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2" `https://www.youtube.com/watch?v=${title.trailerVideoKey}`,
)
}
accessibilityRole="button"
accessibilityLabel={`Play trailer for ${title.title}`}
accessibilityHint="Opens the trailer in YouTube"
className="absolute inset-0 items-center justify-center"
> >
{watchMovie.isPending ? ( {isLiquidGlassAvailable() ? (
<Spinner size="sm" /> <GlassView
glassEffectStyle="clear"
colorScheme="dark"
isInteractive={true}
style={titleDetailStyles.trailerGlass}
>
<IconPlayerPlay size={28} color="white" fill="white" />
</GlassView>
) : ( ) : (
<> <View
<IconCheck size={16} color={titleAccentForeground} /> className="h-14 w-14 items-center justify-center rounded-full"
<Text className="font-sans-medium text-sm text-title-accent-foreground"> style={titleDetailStyles.trailerFallback}
Mark Watched >
</Text> <IconPlayerPlay size={28} color="white" fill="white" />
</> </View>
)} )}
</Pressable> </Pressable>
)} )}
<View className="h-6 w-px bg-border/50" /> <View className="absolute right-0 bottom-0 left-0 flex-row items-end p-4">
{title.posterPath && (
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
accentColor={titleAccent}
/>
</View>
</Animated.View>
{/* Overview */}
{title.overview ? (
<Animated.View entering={FadeIn.duration(300).delay(300)}>
<View className="mt-5 px-4">
<ExpandableText text={title.overview} actionColor={titleAccent} />
</View>
</Animated.View>
) : null}
{/* Availability */}
{availability.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="Where to Watch"
icon={
Platform.OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay
}
iconColor={titleAccent}
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}
>
{availability.map((offer) => (
<View <View
key={`${offer.providerId}-${offer.offerType}`} className="mr-3 h-[150px] w-[100px] overflow-hidden rounded-lg"
className="items-center" style={[{ borderCurve: "continuous" }, posterShadowStyle]}
> >
{offer.logoPath && ( <Image
<Image source={{ uri: title.posterPath }}
source={{ uri: offer.logoPath }} thumbHash={title.posterThumbHash}
style={{ width: 44, height: 44, borderRadius: 10 }} style={titleDetailStyles.posterImage}
contentFit="cover" contentFit="cover"
/> />
)}
<Text
className="mt-1 max-w-[60px] text-center text-[10px] text-muted-foreground"
numberOfLines={1}
>
{offer.providerName}
</Text>
</View> </View>
))} )}
</ScrollView> <View className="flex-1 pb-1">
</Animated.View> <Text
)} className="font-display text-2xl text-white"
numberOfLines={2}
{/* Continue Watching */} >
{title.type === "tv" && ( {title.title}
<ContinueWatchingBanner </Text>
seasons={seasons} <View className="mt-1.5 flex-row flex-wrap items-center gap-2">
watchedEpisodeIds={watchedEpisodeIds} <View className="rounded-full bg-title-accent px-2 py-0.5">
userStatus={userInfo.data?.status ?? null} <Text className="font-sans-medium text-[10px] text-title-accent-foreground">
backdropPath={title.backdropPath} {title.type === "movie" ? "Movie" : "TV"}
backdropThumbHash={title.backdropThumbHash} </Text>
/> </View>
)} {year ? (
<Text className="text-[13px] text-white/70">{year}</Text>
{/* Seasons & Episodes */} ) : null}
{title.type === "tv" && seasons.length > 0 && ( {title.contentRating ? (
<Animated.View <Text className="text-white/50 text-xs">
entering={FadeInDown.duration(300).delay(400)} {title.contentRating}
className="mt-6 px-4" </Text>
> ) : null}
<SectionHeader {title.voteAverage != null && title.voteAverage > 0 && (
title="Seasons" <View className="flex-row items-center gap-0.5">
icon={IconList} <IconStarFilled size={12} color={titleAccent} />
iconColor={titleAccent} <Text className="text-title-accent text-xs">
/> {title.voteAverage.toFixed(1)}
{seasons.map((season) => ( </Text>
<SeasonAccordion </View>
key={season.id} )}
season={season} </View>
episodes={season.episodes ?? []} </View>
watchedEpisodeIds={watchedEpisodeIds} </View>
/>
))}
</Animated.View>
)}
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-title-accent" />
<Text className="mt-2 text-[13px] text-muted-foreground">
Loading season data...
</Text>
</View> </View>
)}
{/* Cast */} {/* Genres */}
{cast.length > 0 && ( {title.genres && title.genres.length > 0 && (
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-3"
contentContainerStyle={titleGenresContentStyle}
>
{title.genres.map((genre: string) => (
<View
key={genre}
className="mr-2 rounded-full bg-secondary px-2.5 py-1"
>
<Text className="text-[11px] text-muted-foreground">
{genre}
</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Actions */}
<Animated.View <Animated.View
entering={FadeInDown.duration(300).delay(500)} entering={FadeInDown.duration(300).delay(200)}
className="mt-6" className="mt-4 px-4"
> >
<View className="px-4"> <View className="flex-row flex-wrap items-center gap-3">
<SectionHeader <StatusActionButton
title="Cast" currentStatus={userInfo.data?.status ?? null}
icon={IconUsers} onStatusChange={(status) => {
iconColor={titleAccent} if (status === "watchlist") {
quickAddMutation.mutate({
tmdbId: title.tmdbId,
type: title.type,
});
} else {
updateStatus.mutate({ id, status: null });
}
}}
isPending={
updateStatus.isPending ||
quickAddMutation.isPending ||
watchMovie.isPending
}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ id })}
disabled={watchMovie.isPending}
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2"
>
{watchMovie.isPending ? (
<Spinner size="sm" />
) : (
<>
<IconCheck size={16} color={titleAccentForeground} />
<Text className="font-sans-medium text-sm text-title-accent-foreground">
Mark Watched
</Text>
</>
)}
</Pressable>
)}
<View className="h-6 w-px bg-border/50" />
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
accentColor={titleAccent}
/> />
</View> </View>
<FlashList </Animated.View>
horizontal
showsHorizontalScrollIndicator={false} {/* Overview */}
data={cast} {title.overview ? (
keyExtractor={(item, index) => `${item.id}-${index}`} <Animated.View entering={FadeIn.duration(300).delay(300)}>
renderItem={renderCastItem} <View className="mt-5 px-4">
contentContainerStyle={castListContentStyle} <ExpandableText text={title.overview} actionColor={titleAccent} />
style={castListStyle} </View>
</Animated.View>
) : null}
{/* Availability */}
{availability.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(400)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="Where to Watch"
icon={providerIcon}
iconColor={titleAccent}
/>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={titleAvailabilityContentStyle}
>
{availability.map((offer) => (
<View
key={`${offer.providerId}-${offer.offerType}`}
className="items-center"
>
{offer.logoPath && (
<Image
source={{ uri: offer.logoPath }}
style={titleDetailStyles.providerLogo}
contentFit="cover"
/>
)}
<Text
className="mt-1 max-w-[60px] text-center text-[10px] text-muted-foreground"
numberOfLines={1}
>
{offer.providerName}
</Text>
</View>
))}
</ScrollView>
</Animated.View>
)}
{/* Continue Watching */}
{title.type === "tv" && (
<ContinueWatchingBanner
seasons={seasons}
watchedEpisodeIds={watchedEpisodeIds}
userStatus={userInfo.data?.status ?? null}
backdropPath={title.backdropPath}
backdropThumbHash={title.backdropThumbHash}
/> />
</Animated.View> )}
)}
{/* Recommendations */} {/* Seasons & Episodes */}
{recItems.length > 0 && ( {title.type === "tv" && seasons.length > 0 && (
<Animated.View <Animated.View
entering={FadeInDown.duration(300).delay(600)} entering={FadeInDown.duration(300).delay(400)}
className="mt-6" className="mt-6 px-4"
> >
<View className="px-4">
<SectionHeader <SectionHeader
title="More Like This" title="Seasons"
icon={IconThumbUp} icon={IconList}
iconColor={titleAccent} iconColor={titleAccent}
/> />
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
season={season}
episodes={season.episodes ?? []}
watchedEpisodeIds={watchedEpisodeIds}
/>
))}
</Animated.View>
)}
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-title-accent" />
<Text className="mt-2 text-[13px] text-muted-foreground">
Loading season data...
</Text>
</View> </View>
<HorizontalPosterRow items={recItems} /> )}
</Animated.View>
)} {/* Cast */}
</ScrollView> {cast.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="Cast"
icon={IconUsers}
iconColor={titleAccent}
/>
</View>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={cast}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={renderCastItem}
contentContainerStyle={castListContentStyle}
style={castListStyle}
/>
</Animated.View>
)}
{/* Recommendations */}
{recItems.length > 0 && (
<Animated.View
entering={FadeInDown.duration(300).delay(600)}
className="mt-6"
>
<View className="px-4">
<SectionHeader
title="More Like This"
icon={IconThumbUp}
iconColor={titleAccent}
/>
</View>
<HorizontalPosterRow items={recItems} />
</Animated.View>
)}
</ScrollView>
</>
); );
} }
+6 -4
View File
@@ -3,7 +3,6 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ScrollView } from "react-native"; import { ScrollView } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SofaLogo } from "@/components/ui/sofa-logo"; import { SofaLogo } from "@/components/ui/sofa-logo";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -20,17 +19,20 @@ export function AuthScreen({
logoStyle, logoStyle,
children, children,
}: AuthScreenProps) { }: AuthScreenProps) {
const insets = useSafeAreaInsets(); const useAutomaticInsets = process.env.EXPO_OS === "ios";
return ( return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}> <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<ScrollView <ScrollView
contentInsetAdjustmentBehavior={
useAutomaticInsets ? "automatic" : "never"
}
contentContainerStyle={{ contentContainerStyle={{
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: insets.top + 24, paddingTop: 24,
paddingBottom: insets.bottom, paddingBottom: 24,
}} }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
bounces={false} bounces={false}
@@ -4,6 +4,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
@@ -29,9 +30,14 @@ export interface ContinueWatchingItem {
} }
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.97]) }], transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.97]),
},
],
})); }));
const tapGesture = Gesture.Tap() const tapGesture = Gesture.Tap()
@@ -42,11 +48,22 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 })); pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
}); });
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`;
const nextEpLabel = item.nextEpisode
? `Next: Season ${item.nextEpisode.seasonNumber} Episode ${item.nextEpisode.episodeNumber}`
: undefined;
const cardLabel = [item.title.title, progressLabel, nextEpLabel]
.filter(Boolean)
.join(", ");
return ( return (
<Link href={`/title/${item.title.id}` as `/title/${string}`}> <Link href={`/title/${item.title.id}` as `/title/${string}`}>
<Link.Trigger> <Link.Trigger>
<GestureDetector gesture={tapGesture}> <GestureDetector gesture={tapGesture}>
<Animated.View <Animated.View
accessible
accessibilityRole="button"
accessibilityLabel={cardLabel}
className="w-[200px] overflow-hidden rounded-[12px] border bg-card" className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
style={[ style={[
animatedStyle, animatedStyle,
@@ -67,6 +84,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
item.nextEpisode?.stillThumbHash ?? item.nextEpisode?.stillThumbHash ??
item.title.backdropThumbHash item.title.backdropThumbHash
} }
recyclingKey={item.title.id}
className="h-full w-full" className="h-full w-full"
contentFit="cover" contentFit="cover"
/> />
@@ -79,7 +97,7 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
<View className="absolute right-2.5 bottom-3 left-2.5"> <View className="absolute right-2.5 bottom-3 left-2.5">
<Text <Text
numberOfLines={1} numberOfLines={1}
className="font-sans-medium text-[11px] text-white/60" className="font-sans-medium text-[11px] text-white/80"
> >
S{item.nextEpisode.seasonNumber} E S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber} {item.nextEpisode.episodeNumber}
@@ -28,7 +28,8 @@ export function HorizontalPosterRow({
items: PosterRowItem[]; items: PosterRowItem[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { handlePress, handleQuickAdd, addingKey } = usePosterActions(); const { handlePress, handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const keyExtractor = useCallback( const keyExtractor = useCallback(
(item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`, (item: PosterRowItem) => item.id ?? `${item.tmdbId}-${item.type}`,
[], [],
@@ -49,9 +50,11 @@ export function HorizontalPosterRow({
onPress={handlePress} onPress={handlePress}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`} isAdding={addingKey === `${item.tmdbId}-${item.type}`}
failedKey={failedKey}
onQuickAddFailed={resetError}
/> />
), ),
[addingKey, handlePress, handleQuickAdd], [addingKey, failedKey, handlePress, handleQuickAdd, resetError],
); );
if (isLoading) { if (isLoading) {
@@ -12,6 +12,7 @@ import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
const genreChipsContentStyle = { paddingHorizontal: 16 };
export function FilterableTitleRow({ export function FilterableTitleRow({
title, title,
@@ -112,7 +113,7 @@ export function FilterableTitleRow({
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
className="mb-3" className="mb-3"
contentContainerStyle={{ paddingHorizontal: 16 }} contentContainerStyle={genreChipsContentStyle}
> >
<GenreChip <GenreChip
label="All" label="All"
@@ -17,6 +17,14 @@ export function GenreChip({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress(); onPress();
}} }}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected: isSelected }}
accessibilityHint={
isSelected
? "Double tap to clear this filter"
: `Double tap to filter by ${label}`
}
className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`} className={`mr-2 rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
> >
<Text <Text
@@ -3,15 +3,15 @@ import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { View } from "react-native"; import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -31,9 +31,14 @@ export interface HeroBannerItem {
export function HeroBanner({ item }: { item: HeroBannerItem }) { export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter(); const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }], transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.98]),
},
],
})); }));
const resolveMutation = useMutation( const resolveMutation = useMutation(
@@ -58,12 +63,16 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
}) })
.onFinalize(() => { .onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 })); pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
scheduleOnRN(handlePress);
}); });
const useGlass = isLiquidGlassAvailable(); const useGlass = isLiquidGlassAvailable();
const accessibilityLabel = [
item.title,
item.releaseDate?.slice(0, 4),
item.voteAverage ? `${item.voteAverage.toFixed(1)} stars` : undefined,
]
.filter(Boolean)
.join(", ");
return ( return (
<GestureDetector gesture={tapGesture}> <GestureDetector gesture={tapGesture}>
@@ -78,57 +87,32 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
}, },
]} ]}
> >
{item.backdropPath && ( <Pressable
<Image onPress={handlePress}
source={{ uri: item.backdropPath }} accessibilityRole="button"
className="absolute h-full w-full" accessibilityLabel={accessibilityLabel}
contentFit="cover" accessibilityHint="Opens title details"
/> style={{ flex: 1 }}
)} >
{useGlass ? ( {item.backdropPath && (
<GlassView <Image
glassEffectStyle="regular" source={{ uri: item.backdropPath }}
colorScheme="dark" className="absolute h-full w-full"
style={{ contentFit="cover"
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/> />
<View className="flex-1 justify-end p-4"> )}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
<Text <Text
className="font-display text-2xl text-white" className="font-display text-2xl text-white"
numberOfLines={2} numberOfLines={2}
@@ -153,9 +137,45 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
{item.releaseDate?.slice(0, 4)} {item.releaseDate?.slice(0, 4)}
</Text> </Text>
</View> </View>
</View> </GlassView>
</> ) : (
)} <>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</>
)}
</Pressable>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );
@@ -2,15 +2,15 @@ import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { View } from "react-native"; import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -30,9 +30,14 @@ export interface HeroBannerItem {
export function HeroBanner({ item }: { item: HeroBannerItem }) { export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter(); const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }], transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.98]),
},
],
})); }));
const resolveMutation = useMutation( const resolveMutation = useMutation(
@@ -57,11 +62,16 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
}) })
.onFinalize(() => { .onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 })); pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
scheduleOnRN(handlePress);
}); });
const accessibilityLabel = [
item.title,
item.releaseDate?.slice(0, 4),
item.voteAverage ? `${item.voteAverage.toFixed(1)} stars` : undefined,
]
.filter(Boolean)
.join(", ");
return ( return (
<GestureDetector gesture={tapGesture}> <GestureDetector gesture={tapGesture}>
<Animated.View <Animated.View
@@ -75,40 +85,52 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
}, },
]} ]}
> >
{item.backdropPath && ( <Pressable
<Image onPress={handlePress}
source={{ uri: item.backdropPath }} accessible
className="absolute h-full w-full" accessibilityRole="button"
contentFit="cover" accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/> />
)} <View className="flex-1 justify-end p-4">
<View <Text
className="absolute inset-0" className="font-display text-2xl text-white"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} numberOfLines={2}
/> >
<View className="flex-1 justify-end p-4"> {item.title}
<Text className="font-display text-2xl text-white" numberOfLines={2}>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text> </Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View> </View>
</View> </Pressable>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );
+8 -1
View File
@@ -20,13 +20,20 @@ export function HeaderAvatar() {
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable <Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)} onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
accessibilityRole="button"
accessibilityLabel="User menu"
hitSlop={8}
> >
<View className="size-8 overflow-hidden rounded-full"> <View
className="size-8 overflow-hidden rounded-full"
accessible={false}
>
{user.image ? ( {user.image ? (
<Image <Image
source={{ uri: user.image }} source={{ uri: user.image }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
contentFit="cover" contentFit="cover"
accessible={false}
/> />
) : ( ) : (
<View className="flex-1 items-center justify-center bg-primary/[0.08]"> <View className="flex-1 items-center justify-center bg-primary/[0.08]">
@@ -31,10 +31,16 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type]; const Icon = TypeIcon[item.type];
const accessibilityLabel = [item.title, TypeLabel[item.type], item.subtitle]
.filter(Boolean)
.join(", ");
return ( return (
<SwipeableRow onDelete={() => onDelete(item.id)}> <SwipeableRow onDelete={() => onDelete(item.id)}>
<Pressable <Pressable
onPress={() => onPress(item)} onPress={() => onPress(item)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="flex-row items-center bg-background px-4 py-3" className="flex-row items-center bg-background px-4 py-3"
style={({ pressed }) => ({ style={({ pressed }) => ({
borderBottomWidth: 0.5, borderBottomWidth: 0.5,
@@ -31,65 +31,86 @@ export const SearchResultRow = memo(function SearchResultRow({
const primary = useCSSVariable("--color-primary") as string; const primary = useCSSVariable("--color-primary") as string;
const imageSrc = item.posterPath ?? item.profilePath; const imageSrc = item.posterPath ?? item.profilePath;
const typeLabel =
item.type === "movie" ? "Movie" : item.type === "tv" ? "TV show" : "Person";
const accessibilityLabel = [
item.title,
typeLabel,
item.releaseDate?.slice(0, 4),
]
.filter(Boolean)
.join(", ");
return ( return (
<Pressable <View
onPress={() => onResolve(item)}
disabled={isResolving}
className="flex-row items-center border-border border-b px-4 py-3" className="flex-row items-center border-border border-b px-4 py-3"
style={{ style={{
borderBottomWidth: 0.5, borderBottomWidth: 0.5,
opacity: isResolving ? 0.6 : 1,
}} }}
> >
<View <Pressable
className="mr-3 overflow-hidden bg-secondary" onPress={() => onResolve(item)}
style={{ disabled={isResolving}
width: 44, accessibilityRole="button"
height: item.type === "person" ? 44 : 66, accessibilityLabel={accessibilityLabel}
borderRadius: item.type === "person" ? 22 : 8, className="flex-1 flex-row items-center"
borderCurve: item.type === "person" ? undefined : "continuous", style={({ pressed }) => ({
}} opacity: pressed || isResolving ? 0.6 : 1,
})}
> >
{imageSrc ? ( <View
<Image className="mr-3 overflow-hidden bg-secondary"
source={{ uri: imageSrc }} style={{
className="h-full w-full" width: 44,
contentFit="cover" height: item.type === "person" ? 44 : 66,
/> borderRadius: item.type === "person" ? 22 : 8,
) : null} borderCurve: item.type === "person" ? undefined : "continuous",
</View> }}
<View className="flex-1">
<Text
numberOfLines={1}
className="font-sans-medium text-[15px] text-foreground"
> >
{item.title} {imageSrc ? (
</Text> <Image
<View className="mt-1 flex-row items-center gap-2"> source={{ uri: imageSrc }}
<View className="rounded-full bg-secondary px-2 py-0.5"> recyclingKey={imageSrc}
<Text className="text-[10px] text-muted-foreground"> className="h-full w-full"
{item.type === "movie" contentFit="cover"
? "Movie" />
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null} ) : null}
</View> </View>
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className="font-sans-medium text-[15px] text-foreground"
>
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="rounded-full bg-secondary px-2 py-0.5">
<Text className="text-[10px] text-muted-foreground">
{item.type === "movie"
? "Movie"
: item.type === "tv"
? "TV"
: "Person"}
</Text>
</View>
{item.releaseDate ? (
<Text className="text-muted-foreground text-xs">
{item.releaseDate.slice(0, 4)}
</Text>
) : null}
</View>
</View>
</Pressable>
{item.type !== "person" && ( {item.type !== "person" && (
<Pressable <Pressable
onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")} onPress={() => onQuickAdd(item.tmdbId, item.type as "movie" | "tv")}
disabled={isAdding} disabled={isAdding || isResolving}
hitSlop={8} accessibilityRole="button"
className="ml-2" accessibilityLabel={`Add ${item.title} to watchlist`}
hitSlop={12}
className="ml-2 items-center justify-center self-stretch"
> >
{isAdding ? ( {isAdding ? (
<IconLoader size={22} color={primary} /> <IconLoader size={22} color={primary} />
@@ -102,6 +123,6 @@ export const SearchResultRow = memo(function SearchResultRow({
{isResolving && ( {isResolving && (
<Spinner size="sm" colorClassName="accent-primary" className="ml-2" /> <Spinner size="sm" colorClassName="accent-primary" className="ml-2" />
)} )}
</Pressable> </View>
); );
}); });
@@ -14,6 +14,7 @@ import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
@@ -47,6 +48,7 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -55,14 +57,24 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
const setupChevronRotation = useSharedValue(0); const setupChevronRotation = useSharedValue(0);
useEffect(() => { useEffect(() => {
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 })); chevronRotation.set(
}, [expanded, chevronRotation]); reduceMotion
? expanded
? 180
: 0
: withTiming(expanded ? 180 : 0, { duration: 200 }),
);
}, [expanded, chevronRotation, reduceMotion]);
useEffect(() => { useEffect(() => {
setupChevronRotation.set( setupChevronRotation.set(
withTiming(setupOpen ? 180 : 0, { duration: 200 }), reduceMotion
? setupOpen
? 180
: 0
: withTiming(setupOpen ? 180 : 0, { duration: 200 }),
); );
}, [setupOpen, setupChevronRotation]); }, [setupOpen, setupChevronRotation, reduceMotion]);
const chevronStyle = useAnimatedStyle(() => ({ const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${chevronRotation.get()}deg` }], transform: [{ rotate: `${chevronRotation.get()}deg` }],
@@ -158,6 +170,9 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
{/* Header */} {/* Header */}
<Pressable <Pressable
onPress={toggleExpanded} onPress={toggleExpanded}
accessibilityRole="button"
accessibilityLabel={`${label}, ${connection ? config.connectedStatus(connection.lastEventAt) : "Not configured"}`}
accessibilityState={{ expanded }}
className="flex-row items-center justify-between p-3" className="flex-row items-center justify-between p-3"
> >
<View className="flex-row items-center gap-3"> <View className="flex-row items-center gap-3">
@@ -233,6 +248,8 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
</Text> </Text>
<Pressable <Pressable
onPress={handleCopy} onPress={handleCopy}
accessibilityRole="button"
accessibilityLabel={copied ? "Copied" : "Copy URL"}
className="ml-2 active:opacity-60" className="ml-2 active:opacity-60"
hitSlop={8} hitSlop={8}
> >
@@ -1,4 +1,4 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
import type { SvgProps } from "react-native-svg"; import type { SvgProps } from "react-native-svg";
import { import {
EmbyIcon, EmbyIcon,
@@ -1,7 +1,7 @@
import type { Icon } from "@tabler/icons-react-native"; import type { Icon } from "@tabler/icons-react-native";
import { IconChevronRight } from "@tabler/icons-react-native"; import { IconChevronRight } from "@tabler/icons-react-native";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Pressable } from "react-native"; import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -25,13 +25,9 @@ export function SettingsRow({
const destructiveColor = useCSSVariable("--color-destructive") as string; const destructiveColor = useCSSVariable("--color-destructive") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return ( const isDisabled = disabled || !onPress;
<Pressable const content = (
onPress={onPress} <>
disabled={disabled || !onPress}
className="flex-row items-center py-3.5"
style={disabled ? { opacity: 0.5 } : undefined}
>
{IconComponent && ( {IconComponent && (
<IconComponent <IconComponent
size={18} size={18}
@@ -46,14 +42,41 @@ export function SettingsRow({
{right} {right}
{!right && value ? ( {!right && value ? (
<Text <Text
selectable selectable={!onPress}
className="mr-1 max-w-[160px] text-[14px] text-muted-foreground" className="mr-1 max-w-[180px] shrink text-right text-[14px] text-muted-foreground"
numberOfLines={1} numberOfLines={2}
> >
{value} {value}
</Text> </Text>
) : null} ) : null}
{!right && onPress && <IconChevronRight size={16} color={mutedFgColor} />} {!right && onPress && (
<IconChevronRight size={16} color={mutedFgColor} accessible={false} />
)}
</>
);
if (!onPress) {
return (
<View
className="flex-row items-center py-3.5"
style={disabled ? { opacity: 0.5 } : undefined}
>
{content}
</View>
);
}
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
accessibilityRole="button"
accessibilityLabel={value ? `${label}, ${value}` : label}
accessibilityState={{ disabled: !!isDisabled }}
className="flex-row items-center py-3.5"
style={disabled ? { opacity: 0.5 } : undefined}
>
{content}
</Pressable> </Pressable>
); );
} }
@@ -15,18 +15,28 @@ export function CastCard({
profileThumbHash?: string | null; profileThumbHash?: string | null;
}; };
}) { }) {
const accessibilityLabel = person.character
? `${person.name} as ${person.character}`
: person.name;
return ( return (
<Link href={`/person/${person.personId}` as `/person/${string}`}> <Link href={`/person/${person.personId}` as `/person/${string}`}>
<Link.Trigger> <Link.Trigger>
<Pressable style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> <Pressable
accessibilityRole="link"
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View className="w-20 items-center"> <View className="w-20 items-center">
<View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary"> <View className="mb-2 h-16 w-16 overflow-hidden rounded-full bg-secondary">
{person.profilePath && ( {person.profilePath && (
<Image <Image
source={{ uri: person.profilePath }} source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash} thumbHash={person.profileThumbHash}
recyclingKey={person.personId}
className="h-full w-full" className="h-full w-full"
contentFit="cover" contentFit="cover"
accessible={false}
/> />
)} )}
</View> </View>
@@ -23,9 +23,14 @@ export function EpisodeRow({
accentColor: string; accentColor: string;
mutedColor: string; mutedColor: string;
}) { }) {
const episodeLabel = episode.name ?? `Episode ${episode.episodeNumber}`;
return ( return (
<Pressable <Pressable
onPress={onToggle} onPress={onToggle}
accessibilityRole="checkbox"
accessibilityState={{ checked: isWatched }}
accessibilityLabel={`Episode ${episode.episodeNumber}, ${episodeLabel}`}
className="flex-row items-center border-border border-b px-4 py-3" className="flex-row items-center border-border border-b px-4 py-3"
style={{ borderBottomWidth: 0.5 }} style={{ borderBottomWidth: 0.5 }}
> >
@@ -6,6 +6,7 @@ import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
@@ -37,6 +38,7 @@ export function SeasonAccordion({
const titleAccentColor = useCSSVariable("--color-title-accent") as string; const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const reduceMotion = useReducedMotion();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const chevronRotation = useSharedValue(0); const chevronRotation = useSharedValue(0);
const watchedCount = episodes.filter((e) => const watchedCount = episodes.filter((e) =>
@@ -63,8 +65,14 @@ export function SeasonAccordion({
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []); const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
useEffect(() => { useEffect(() => {
chevronRotation.set(withTiming(expanded ? 180 : 0, { duration: 200 })); chevronRotation.set(
}, [expanded, chevronRotation]); reduceMotion
? expanded
? 180
: 0
: withTiming(expanded ? 180 : 0, { duration: 200 }),
);
}, [expanded, chevronRotation, reduceMotion]);
const chevronStyle = useAnimatedStyle(() => ({ const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${chevronRotation.get()}deg` }], transform: [{ rotate: `${chevronRotation.get()}deg` }],
@@ -136,6 +144,9 @@ export function SeasonAccordion({
> >
<Pressable <Pressable
onPress={toggleExpanded} onPress={toggleExpanded}
accessibilityRole="button"
accessibilityLabel={`${season.name ?? `Season ${season.seasonNumber}`}, ${watchedCount} of ${episodes.length} episodes watched`}
accessibilityState={{ expanded }}
className="flex-row items-center justify-between p-4" className="flex-row items-center justify-between p-4"
> >
<View className="flex-1"> <View className="flex-1">
@@ -1,4 +1,5 @@
import { import {
IconBookmarkFilled,
IconCheck, IconCheck,
IconPlayerPlayFilled, IconPlayerPlayFilled,
IconPlus, IconPlus,
@@ -10,16 +11,19 @@ import * as Haptics from "@/utils/haptics";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
const watchingStyle = {
label: "Watching",
Icon: IconPlayerPlayFilled,
bgClass: "bg-title-accent/10 border-title-accent/20",
textClass: "text-title-accent",
};
const statusConfig = { const statusConfig = {
watchlist: watchingStyle, watchlist: {
in_progress: watchingStyle, label: "Watchlist",
Icon: IconBookmarkFilled,
bgClass: "bg-title-accent/10 border-title-accent/20",
textClass: "text-title-accent",
},
in_progress: {
label: "Watching",
Icon: IconPlayerPlayFilled,
bgClass: "bg-title-accent/10 border-title-accent/20",
textClass: "text-title-accent",
},
completed: { completed: {
label: "Completed", label: "Completed",
Icon: IconCheck, Icon: IconCheck,
@@ -37,10 +41,11 @@ export function StatusActionButton({
onStatusChange: (status: TitleStatus | null) => void; onStatusChange: (status: TitleStatus | null) => void;
isPending: boolean; isPending: boolean;
}) { }) {
const [titleAccent, completedColor] = useCSSVariable([ const [titleAccent, completedColor, watchlistColor] = useCSSVariable([
"--color-title-accent", "--color-title-accent",
"--color-status-completed", "--color-status-completed",
]) as [string, string]; "--color-status-watchlist",
]) as [string, string, string];
const config = currentStatus const config = currentStatus
? statusConfig[currentStatus as keyof typeof statusConfig] ? statusConfig[currentStatus as keyof typeof statusConfig]
@@ -54,6 +59,8 @@ export function StatusActionButton({
onStatusChange("watchlist"); onStatusChange("watchlist");
}} }}
disabled={isPending} disabled={isPending}
accessibilityRole="button"
accessibilityLabel="Add to watchlist"
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2" className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
> >
<IconPlus size={14} color={titleAccent} strokeWidth={2.5} /> <IconPlus size={14} color={titleAccent} strokeWidth={2.5} />
@@ -65,7 +72,11 @@ export function StatusActionButton({
} }
const iconColor = const iconColor =
currentStatus === "completed" ? completedColor : titleAccent; currentStatus === "completed"
? completedColor
: currentStatus === "watchlist"
? watchlistColor
: titleAccent;
return ( return (
<Pressable <Pressable
@@ -74,6 +85,8 @@ export function StatusActionButton({
onStatusChange(null); onStatusChange(null);
}} }}
disabled={isPending} disabled={isPending}
accessibilityRole="button"
accessibilityLabel={`${config.label}, double tap to remove`}
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${config.bgClass}`} className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${config.bgClass}`}
> >
<config.Icon size={14} color={iconColor} /> <config.Icon size={14} color={iconColor} />
+3 -1
View File
@@ -34,9 +34,11 @@ export const Button = forwardRef<
<Pressable <Pressable
ref={ref} ref={ref}
disabled={disabled} disabled={disabled}
accessibilityRole="button"
accessibilityState={{ disabled: !!disabled }}
className={cn( className={cn(
"items-center justify-center rounded-xl", "items-center justify-center rounded-xl",
size === "sm" ? "h-10 px-4" : "h-12 px-5", size === "sm" ? "min-h-10 px-4 py-2" : "min-h-12 px-5 py-3",
variant === "secondary" ? "bg-secondary" : "bg-primary", variant === "secondary" ? "bg-secondary" : "bg-primary",
className, className,
)} )}
@@ -1,5 +1,6 @@
import type { Icon } from "@tabler/icons-react-native"; import type { Icon } from "@tabler/icons-react-native";
import { IconMovie } from "@tabler/icons-react-native"; import { IconMovie } from "@tabler/icons-react-native";
import { View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Button, ButtonLabel } from "@/components/ui/button"; import { Button, ButtonLabel } from "@/components/ui/button";
@@ -26,8 +27,11 @@ export function EmptyState({
<Animated.View <Animated.View
entering={FadeIn.duration(400)} entering={FadeIn.duration(400)}
className="items-center justify-center px-6 py-12" className="items-center justify-center px-6 py-12"
accessibilityLabel={description ? `${title}. ${description}` : title}
> >
<IconComponent size={48} color={mutedForeground} /> <View accessible={false}>
<IconComponent size={48} color={mutedForeground} />
</View>
<Text className="mt-3 text-center font-sans-medium text-[16px] text-foreground"> <Text className="mt-3 text-center font-sans-medium text-[16px] text-foreground">
{title} {title}
</Text> </Text>
@@ -53,7 +53,12 @@ export function ExpandableText({
{text} {text}
</Text> </Text>
{needsTruncation && ( {needsTruncation && (
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1"> <Pressable
onPress={() => setExpanded(!expanded)}
accessibilityRole="button"
accessibilityLabel={expanded ? "Show less" : "Show more"}
className="mt-1"
>
<Text <Text
className="font-sans-medium text-[13px] text-primary" className="font-sans-medium text-[13px] text-primary"
style={actionColor ? { color: actionColor } : undefined} style={actionColor ? { color: actionColor } : undefined}
+75 -33
View File
@@ -14,10 +14,10 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import * as ContextMenu from "zeego/context-menu"; import * as ContextMenu from "zeego/context-menu";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
@@ -48,6 +48,8 @@ interface PosterCardProps {
) => void; ) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void; onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isAdding?: boolean; isAdding?: boolean;
failedKey?: string | null;
onQuickAddFailed?: () => void;
} }
export function PosterCard({ export function PosterCard({
@@ -65,6 +67,8 @@ export function PosterCard({
onPress, onPress,
onQuickAdd, onQuickAdd,
isAdding, isAdding,
failedKey,
onQuickAddFailed,
}: PosterCardProps) { }: PosterCardProps) {
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string; const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
@@ -77,6 +81,7 @@ export function PosterCard({
completed: completedColor, completed: completedColor,
}; };
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); const pressed = useSharedValue(0);
const [localStatus, setLocalStatus] = useState<TitleStatus | null>( const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
userStatus ?? null, userStatus ?? null,
@@ -86,10 +91,17 @@ export function PosterCard({
setLocalStatus(userStatus ?? null); setLocalStatus(userStatus ?? null);
}, [userStatus]); }, [userStatus]);
useEffect(() => {
if (failedKey === `${tmdbId}-${type}`) {
setLocalStatus(userStatus ?? null);
onQuickAddFailed?.();
}
}, [failedKey, tmdbId, type, userStatus, onQuickAddFailed]);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
scale: interpolate(pressed.get(), [0, 1], [1, 0.97]), scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.97]),
}, },
], ],
})); }));
@@ -107,6 +119,23 @@ export function PosterCard({
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5; const imageHeight = width * 1.5;
const statusLabel =
localStatus === "completed"
? "completed"
: localStatus === "in_progress"
? "watching"
: localStatus === "watchlist"
? "on watchlist"
: undefined;
const cardAccessibilityLabel = [
title,
type === "movie" ? "movie" : "TV show",
year,
statusLabel,
]
.filter(Boolean)
.join(", ");
const cardContent = ( const cardContent = (
<View <View
className={`overflow-hidden rounded-xl border bg-card ${ className={`overflow-hidden rounded-xl border bg-card ${
@@ -133,24 +162,10 @@ export function PosterCard({
</View> </View>
)} )}
{/* Quick-add button */}
{!localStatus && (
<Pressable
onPress={handleQuickAddPress}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{isAdding ? (
<IconLoader size={16} color="white" />
) : (
<IconPlus size={16} color="white" />
)}
</Pressable>
)}
{/* Status indicator */} {/* Status indicator */}
{localStatus && ( {localStatus && (
<View <View
accessible={false}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full" className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
> >
@@ -198,7 +213,7 @@ export function PosterCard({
{title} {title}
</Text> </Text>
</View> </View>
<View className="mt-1 flex-row items-center gap-1"> <View className="mt-1 flex-row items-center gap-1" accessible={false}>
{type === "movie" ? ( {type === "movie" ? (
<IconMovie size={12} color={primaryColor} /> <IconMovie size={12} color={primaryColor} />
) : ( ) : (
@@ -219,6 +234,27 @@ export function PosterCard({
</View> </View>
</View> </View>
); );
const quickAddButton = !localStatus ? (
<Pressable
onPress={handleQuickAddPress}
disabled={!!isAdding}
accessibilityRole="button"
accessibilityLabel={`Add ${title} to watchlist`}
accessibilityState={{ disabled: !!isAdding }}
className="absolute top-1 right-1 size-[44px] items-center justify-center rounded-full"
>
<View
className="size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{isAdding ? (
<IconLoader size={16} color="white" />
) : (
<IconPlus size={16} color="white" />
)}
</View>
</Pressable>
) : null;
// Gesture for UI-thread press animation (used by both paths) // Gesture for UI-thread press animation (used by both paths)
const pressGesture = Gesture.Tap() const pressGesture = Gesture.Tap()
@@ -236,7 +272,16 @@ export function PosterCard({
<ContextMenu.Trigger> <ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}> <GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}> <Animated.View style={[animatedStyle, { width }]}>
<Pressable onPress={handlePressAction}>{cardContent}</Pressable> <View>
<Pressable
onPress={handlePressAction}
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
{quickAddButton}
</View>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</ContextMenu.Trigger> </ContextMenu.Trigger>
@@ -313,22 +358,19 @@ export function PosterCard({
); );
} }
// Cards without id: use GestureDetector for resolve-then-navigate
const resolveTapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
scheduleOnRN(handlePressAction);
});
return ( return (
<GestureDetector gesture={resolveTapGesture}> <GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}> <Animated.View style={[animatedStyle, { width }]}>
{cardContent} <View>
<Pressable
onPress={handlePressAction}
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
{quickAddButton}
</View>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );
+9 -3
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import type { ViewStyle } from "react-native"; import type { ViewStyle } from "react-native";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withRepeat, withRepeat,
withTiming, withTiming,
@@ -23,11 +24,14 @@ export function Skeleton({
style, style,
}: SkeletonProps) { }: SkeletonProps) {
const secondaryColor = useCSSVariable("--color-secondary") as string; const secondaryColor = useCSSVariable("--color-secondary") as string;
const opacity = useSharedValue(0.4); const reduceMotion = useReducedMotion();
const opacity = useSharedValue(reduceMotion ? 0.7 : 0.4);
useEffect(() => { useEffect(() => {
opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true)); if (!reduceMotion) {
}, [opacity]); opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true));
}
}, [opacity, reduceMotion]);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.get(), opacity: opacity.get(),
@@ -35,6 +39,8 @@ export function Skeleton({
return ( return (
<Animated.View <Animated.View
accessible
accessibilityLabel="Loading"
style={[ style={[
{ {
width, width,
+15 -2
View File
@@ -26,17 +26,30 @@ export function StarRating({
const primary = accentColor ?? defaultPrimary; const primary = accentColor ?? defaultPrimary;
return ( return (
<View className="flex-row items-center gap-1"> <View
className="flex-row items-center gap-1"
accessibilityLabel={`Rating: ${rating} out of 5 stars`}
accessibilityRole="adjustable"
>
{[1, 2, 3, 4, 5].map((star) => ( {[1, 2, 3, 4, 5].map((star) => (
<Pressable <Pressable
key={star} key={star}
disabled={!interactive} disabled={!interactive}
accessibilityRole="button"
accessibilityLabel={`${star} star${star !== 1 ? "s" : ""}`}
accessibilityHint={
interactive
? star === rating
? "Double tap to clear rating"
: `Double tap to rate ${star} star${star !== 1 ? "s" : ""}`
: undefined
}
onPress={() => { onPress={() => {
if (!onRate) return; if (!onRate) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRate(star === rating ? 0 : star); onRate(star === rating ? 0 : star);
}} }}
hitSlop={6} hitSlop={10}
> >
{star <= rating ? ( {star <= rating ? (
<IconStarFilled size={size} color={primary} /> <IconStarFilled size={size} color={primary} />
+19 -10
View File
@@ -38,19 +38,28 @@ interface SwipeableRowProps extends PropsWithChildren {
export function SwipeableRow({ onDelete, children }: SwipeableRowProps) { export function SwipeableRow({ onDelete, children }: SwipeableRowProps) {
return ( return (
<ReanimatedSwipeable <View
friction={2} accessibilityActions={[{ name: "delete", label: "Delete" }]}
rightThreshold={40} onAccessibilityAction={(event) => {
overshootRight={false} if (event.nativeEvent.actionName === "delete") {
renderRightActions={(_progress, drag) => <RightAction drag={drag} />}
onSwipeableOpen={(direction) => {
if (direction === "left") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onDelete(); onDelete();
} }
}} }}
> >
{children} <ReanimatedSwipeable
</ReanimatedSwipeable> friction={2}
rightThreshold={40}
overshootRight={false}
renderRightActions={(_progress, drag) => <RightAction drag={drag} />}
onSwipeableOpen={(direction) => {
if (direction === "left") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onDelete();
}
}}
>
{children}
</ReanimatedSwipeable>
</View>
); );
} }
+21 -69
View File
@@ -1,88 +1,40 @@
import { Pressable } from "react-native"; import { Switch as RNSwitch } from "react-native";
import Animated, {
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
const TRACK_WIDTH = 40;
const TRACK_HEIGHT = 20;
const THUMB_SIZE = 14;
const THUMB_OFFSET = 3;
export function Switch({ export function Switch({
value, value,
onValueChange, onValueChange,
disabled, disabled,
accessibilityLabel,
}: { }: {
value: boolean; value: boolean;
onValueChange: (value: boolean) => void; onValueChange: (value: boolean) => void;
disabled?: boolean; disabled?: boolean;
accessibilityLabel?: string;
}) { }) {
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const secondaryColor = useCSSVariable("--color-secondary") as string; const secondaryColor = useCSSVariable("--color-secondary") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string; const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const switchScale = process.env.EXPO_OS === "ios" ? 0.84 : 0.9;
const progress = useDerivedValue(() =>
withTiming(value ? 1 : 0, { duration: 200 }),
);
const trackStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.get(),
[0, 1],
[secondaryColor, primaryColor],
),
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [
{
translateX:
THUMB_OFFSET +
progress.get() * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
},
],
backgroundColor: interpolateColor(
progress.get(),
[0, 1],
[mutedFgColor, "#fff"],
),
}));
return ( return (
<Pressable <RNSwitch
onPress={() => onValueChange(!value)} value={value}
onValueChange={onValueChange}
disabled={disabled} disabled={disabled}
hitSlop={8} accessibilityLabel={accessibilityLabel}
style={disabled ? { opacity: 0.5 } : undefined} ios_backgroundColor={secondaryColor}
> thumbColor={
<Animated.View process.env.EXPO_OS === "ios"
style={[ ? undefined
{ : value
width: TRACK_WIDTH, ? "#fff"
height: TRACK_HEIGHT, : mutedFgColor
borderRadius: TRACK_HEIGHT / 2, }
}, trackColor={{ false: secondaryColor, true: primaryColor }}
trackStyle, style={{
]} transform: [{ scaleX: switchScale }, { scaleY: switchScale }],
> }}
<Animated.View />
style={[
{
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: THUMB_SIZE / 2,
position: "absolute",
top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
},
thumbStyle,
]}
/>
</Animated.View>
</Pressable>
); );
} }
+27 -5
View File
@@ -1,4 +1,10 @@
import { forwardRef, type PropsWithChildren } from "react"; import {
createContext,
forwardRef,
type PropsWithChildren,
useContext,
useId,
} from "react";
import { import {
TextInput, TextInput,
type TextInputProps, type TextInputProps,
@@ -9,16 +15,26 @@ import { Text } from "@/components/ui/text";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
const TextFieldContext = createContext<string | undefined>(undefined);
export function TextField({ children }: PropsWithChildren) { export function TextField({ children }: PropsWithChildren) {
return <View className="gap-1.5">{children}</View>; const id = useId();
return (
<TextFieldContext.Provider value={id}>
<View className="gap-1.5">{children}</View>
</TextFieldContext.Provider>
);
} }
export function Label({ export function Label({
className, className,
nativeID,
...props ...props
}: TextProps & { className?: string }) { }: TextProps & { className?: string }) {
const ctxId = useContext(TextFieldContext);
return ( return (
<Text <Text
nativeID={nativeID ?? ctxId}
className={cn("font-sans-medium text-foreground text-sm", className)} className={cn("font-sans-medium text-foreground text-sm", className)}
{...props} {...props}
/> />
@@ -29,12 +45,14 @@ export const Input = forwardRef<
TextInput, TextInput,
TextInputProps & { className?: string } TextInputProps & { className?: string }
>(({ className, style, ...props }, ref) => { >(({ className, style, ...props }, ref) => {
const ctxId = useContext(TextFieldContext);
return ( return (
<TextInput <TextInput
ref={ref} ref={ref}
placeholderTextColorClassName="accent-muted-foreground/50" accessibilityLabelledBy={ctxId}
placeholderTextColorClassName="accent-muted-foreground/70"
className={cn( className={cn(
"h-12 rounded-[12px] border border-border bg-input px-3.5 font-sans text-[15px] text-foreground", "min-h-12 rounded-[12px] border border-border bg-input px-3.5 py-3 font-sans text-[15px] text-foreground",
className, className,
)} )}
style={[{ borderCurve: "continuous" }, style]} style={[{ borderCurve: "continuous" }, style]}
@@ -53,7 +71,11 @@ export function FieldError({
}: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) { }: PropsWithChildren<TextProps & { isInvalid?: boolean; className?: string }>) {
if (!isInvalid) return null; if (!isInvalid) return null;
return ( return (
<Text className={cn("text-[13px] text-destructive", className)} {...props}> <Text
selectable
className={cn("text-[13px] text-destructive", className)}
{...props}
>
{children} {children}
</Text> </Text>
); );
+8 -1
View File
@@ -4,7 +4,14 @@ import { cn } from "@/utils/cn";
export function Text({ export function Text({
className, className,
maxFontSizeMultiplier,
...props ...props
}: TextProps & { className?: string }) { }: TextProps & { className?: string }) {
return <RNText className={cn("font-sans", className)} {...props} />; return (
<RNText
className={cn("font-sans", className)}
maxFontSizeMultiplier={maxFontSizeMultiplier}
{...props}
/>
);
} }
+1 -1
View File
@@ -22,7 +22,7 @@
--color-secondary: oklch(0.22 0.008 55); --color-secondary: oklch(0.22 0.008 55);
--color-secondary-foreground: oklch(0.85 0.02 80); --color-secondary-foreground: oklch(0.85 0.02 80);
--color-muted: oklch(0.22 0.008 55); --color-muted: oklch(0.22 0.008 55);
--color-muted-foreground: oklch(0.63 0.02 80); --color-muted-foreground: oklch(0.71 0.02 80);
--color-accent: oklch(0.25 0.01 55); --color-accent: oklch(0.25 0.01 55);
--color-accent-foreground: oklch(0.93 0.015 80); --color-accent-foreground: oklch(0.93 0.015 80);
--color-destructive: oklch(0.65 0.2 25); --color-destructive: oklch(0.65 0.2 25);
+10 -1
View File
@@ -60,5 +60,14 @@ export function usePosterActions() {
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}` ? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
: null; : null;
return { handlePress, handleQuickAdd, addingKey }; const failedKey =
quickAddMutation.isError && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
: null;
const resetError = useCallback(() => {
quickAddMutation.reset();
}, [quickAddMutation.reset]);
return { handlePress, handleQuickAdd, addingKey, failedKey, resetError };
} }