mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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="••••••••"
|
||||||
|
|||||||
@@ -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="••••••••"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user