refactor(native): list optimizations and cleanup

This commit is contained in:
2026-03-14 13:46:12 -04:00
parent 3148574d5b
commit c6cd061175
13 changed files with 199 additions and 184 deletions
-1
View File
@@ -20,7 +20,6 @@
"@expo-google-fonts/geist-mono": "0.4.1", "@expo-google-fonts/geist-mono": "0.4.1",
"@expo/metro-runtime": "55.0.6", "@expo/metro-runtime": "55.0.6",
"@expo/ui": "55.0.2", "@expo/ui": "55.0.2",
"@gorhom/bottom-sheet": "5.2.8",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:", "@orpc/tanstack-query": "catalog:",
+7 -1
View File
@@ -19,6 +19,7 @@ import { PosterCard } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header"; import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { usePosterActions } from "@/hooks/use-poster-actions";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { addRecentlyViewed } from "@/lib/recently-viewed"; import { addRecentlyViewed } from "@/lib/recently-viewed";
@@ -55,6 +56,8 @@ export default function PersonDetailScreen() {
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 { data, isPending, isError } = useQuery( const { data, isPending, isError } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }), orpc.people.detail.queryOptions({ input: { id } }),
); );
@@ -91,10 +94,13 @@ export default function PersonDetailScreen() {
voteAverage={credit.voteAverage} voteAverage={credit.voteAverage}
userStatus={data?.userStatuses?.[credit.titleId] ?? null} userStatus={data?.userStatuses?.[credit.titleId] ?? null}
width={undefined} width={undefined}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${credit.tmdbId}-${credit.type}`}
/> />
</View> </View>
), ),
[columnWidth, data?.userStatuses], [columnWidth, data?.userStatuses, handlePress, handleQuickAdd, addingKey],
); );
if (isPending) { if (isPending) {
+25 -35
View File
@@ -14,18 +14,21 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient"; 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, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import { FlatList, Platform, Pressable, ScrollView, View } from "react-native"; import { FlatList, Platform, Pressable, ScrollView, 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";
import {
HorizontalPosterRow,
type PosterRowItem,
} from "@/components/dashboard/horizontal-poster-row";
import { CastCard } from "@/components/titles/cast-card"; import { CastCard } from "@/components/titles/cast-card";
import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner"; import { ContinueWatchingBanner } from "@/components/titles/continue-watching-banner";
import { SeasonAccordion } from "@/components/titles/season-accordion"; import { SeasonAccordion } from "@/components/titles/season-accordion";
import { StatusActionButton } from "@/components/titles/status-action-button"; import { StatusActionButton } from "@/components/titles/status-action-button";
import { ExpandableText } from "@/components/ui/expandable-text"; import { ExpandableText } from "@/components/ui/expandable-text";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { PosterCard } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header"; import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -124,13 +127,6 @@ export default function TitleDetailScreen() {
}), }),
); );
const [_refreshing, setRefreshing] = useState(false);
const _onRefresh = useCallback(async () => {
setRefreshing(true);
await queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
setRefreshing(false);
}, []);
const title = detail.data?.title; const title = detail.data?.title;
const palette = title?.colorPalette ?? null; const palette = title?.colorPalette ?? null;
useTitleTheme(palette); useTitleTheme(palette);
@@ -161,6 +157,24 @@ export default function TitleDetailScreen() {
[userInfo.data?.episodeWatches], [userInfo.data?.episodeWatches],
); );
const recItems = useMemo<PosterRowItem[]>(
() =>
(recommendations.data?.recommendations ?? []).map((item) => ({
id: item.id,
tmdbId: item.tmdbId,
title: item.title,
type: item.type,
posterPath: item.posterPath,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
voteAverage: item.voteAverage,
userStatus: item.id
? (recommendations.data?.userStatuses?.[item.id] ?? null)
: null,
})),
[recommendations.data],
);
const hydratedTitleId = useRef<string | null>(null); const hydratedTitleId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if ( if (
@@ -592,8 +606,7 @@ export default function TitleDetailScreen() {
)} )}
{/* Recommendations */} {/* Recommendations */}
{recommendations.data && {recItems.length > 0 && (
recommendations.data.recommendations?.length > 0 && (
<Animated.View <Animated.View
entering={FadeInDown.duration(300).delay(600)} entering={FadeInDown.duration(300).delay(600)}
className="mt-6" className="mt-6"
@@ -605,30 +618,7 @@ export default function TitleDetailScreen() {
iconColor={titleAccent} iconColor={titleAccent}
/> />
</View> </View>
<FlatList <HorizontalPosterRow items={recItems} />
horizontal
showsHorizontalScrollIndicator={false}
data={recommendations.data.recommendations}
keyExtractor={(item) => item.id ?? String(item.tmdbId)}
renderItem={({ item }) => (
<PosterCard
id={item.id}
tmdbId={item.tmdbId}
title={item.title}
type={item.type}
posterPath={item.posterPath}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={
item.id
? (recommendations.data?.userStatuses?.[item.id] ?? null)
: null
}
/>
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
</Animated.View> </Animated.View>
)} )}
</ScrollView> </ScrollView>
@@ -1,9 +1,10 @@
import { FlatList } from "react-native"; import { FlatList } from "react-native";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card"; import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions";
export interface PosterRowItem { export interface PosterRowItem {
id: string; id?: string;
tmdbId: number; tmdbId: number;
title: string; title: string;
type: string; type: string;
@@ -15,6 +16,9 @@ export interface PosterRowItem {
episodeProgress?: { watched: number; total: number } | null; episodeProgress?: { watched: number; total: number } | null;
} }
const listContentStyle = { gap: 12, paddingHorizontal: 16 };
const listStyle = { overflow: "visible" as const };
export function HorizontalPosterRow({ export function HorizontalPosterRow({
items, items,
isLoading, isLoading,
@@ -22,6 +26,8 @@ export function HorizontalPosterRow({
items: PosterRowItem[]; items: PosterRowItem[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { handlePress, handleQuickAdd, addingKey } = usePosterActions();
if (isLoading) { if (isLoading) {
return ( return (
<FlatList <FlatList
@@ -30,8 +36,8 @@ export function HorizontalPosterRow({
data={[1, 2, 3, 4]} data={[1, 2, 3, 4]}
keyExtractor={(item) => String(item)} keyExtractor={(item) => String(item)}
renderItem={() => <PosterCardSkeleton />} renderItem={() => <PosterCardSkeleton />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }} contentContainerStyle={listContentStyle}
style={{ overflow: "visible" }} style={listStyle}
/> />
); );
} }
@@ -41,7 +47,7 @@ export function HorizontalPosterRow({
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
data={items} data={items}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id ?? `${item.tmdbId}-${item.type}`}
renderItem={({ item }) => ( renderItem={({ item }) => (
<PosterCard <PosterCard
id={item.id} id={item.id}
@@ -53,10 +59,13 @@ export function HorizontalPosterRow({
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
userStatus={item.userStatus} userStatus={item.userStatus}
episodeProgress={item.episodeProgress} episodeProgress={item.episodeProgress}
onPress={handlePress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === `${item.tmdbId}-${item.type}`}
/> />
)} )}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }} contentContainerStyle={listContentStyle}
style={{ overflow: "visible" }} style={listStyle}
/> />
); );
} }
@@ -1,9 +1,12 @@
import type { Icon } from "@tabler/icons-react-native"; import type { Icon } from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useMemo, useState } from "react";
import { FlatList, ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import {
HorizontalPosterRow,
type PosterRowItem,
} from "@/components/dashboard/horizontal-poster-row";
import { GenreChip } from "@/components/explore/genre-chip"; import { GenreChip } from "@/components/explore/genre-chip";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { SectionHeader } from "@/components/ui/section-header"; import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
@@ -46,7 +49,7 @@ export function FilterableTitleRow({
enabled: selectedGenre !== null, enabled: selectedGenre !== null,
}); });
const items = const rawItems =
selectedGenre === null ? defaultItems : (discover.data?.items ?? []); selectedGenre === null ? defaultItems : (discover.data?.items ?? []);
const userStatuses = const userStatuses =
selectedGenre === null selectedGenre === null
@@ -59,6 +62,20 @@ export function FilterableTitleRow({
const showLoading = const showLoading =
isLoading || (selectedGenre !== null && discover.isPending); isLoading || (selectedGenre !== null && discover.isPending);
// Map items into PosterRowItem shape with status/progress resolved
const items = useMemo<PosterRowItem[]>(
() =>
rawItems.map((item) => {
const key = `${item.tmdbId}-${item.type}`;
return {
...item,
userStatus: userStatuses[key] ?? null,
episodeProgress: episodeProgress[key] ?? null,
};
}),
[rawItems, userStatuses, episodeProgress],
);
return ( return (
<View> <View>
<View className="px-4"> <View className="px-4">
@@ -90,45 +107,14 @@ export function FilterableTitleRow({
</ScrollView> </ScrollView>
)} )}
{showLoading ? ( {!showLoading && items.length === 0 && selectedGenre !== null ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={[1, 2, 3, 4]}
keyExtractor={(item) => String(item)}
renderItem={() => <PosterCardSkeleton />}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
) : items.length === 0 && selectedGenre !== null ? (
<View className="items-center py-6"> <View className="items-center py-6">
<Text className="text-[13px] text-muted-foreground"> <Text className="text-[13px] text-muted-foreground">
No titles found for this genre. No titles found for this genre.
</Text> </Text>
</View> </View>
) : ( ) : (
<FlatList <HorizontalPosterRow items={items} isLoading={showLoading} />
horizontal
showsHorizontalScrollIndicator={false}
data={items}
keyExtractor={(item) => `${item.tmdbId}-${item.type}`}
renderItem={({ item }) => (
<PosterCard
tmdbId={item.tmdbId}
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`] ?? null}
episodeProgress={
episodeProgress[`${item.tmdbId}-${item.type}`] ?? null
}
/>
)}
contentContainerStyle={{ gap: 12, paddingHorizontal: 16 }}
style={{ overflow: "visible" }}
/>
)} )}
</View> </View>
); );
@@ -1,12 +1,5 @@
import { Link } from "expo-router"; import { Link } from "expo-router";
import { View } from "react-native"; import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -21,24 +14,11 @@ export function CastCard({
profilePath: string | null; profilePath: string | null;
}; };
}) { }) {
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
return ( return (
<Link href={`/person/${person.personId}` as `/person/${string}`}> <Link href={`/person/${person.personId}` as `/person/${string}`}>
<Link.Trigger> <Link.Trigger>
<GestureDetector gesture={tapGesture}> <Pressable style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>
<Animated.View className="w-20 items-center" style={animatedStyle}> <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
@@ -62,8 +42,8 @@ export function CastCard({
{person.character} {person.character}
</Text> </Text>
) : null} ) : null}
</Animated.View> </View>
</GestureDetector> </Pressable>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <Link.Preview />
</Link> </Link>
@@ -3,13 +3,14 @@ import {
IconCircleDashed, IconCircleDashed,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
export function EpisodeRow({ export function EpisodeRow({
episode, episode,
isWatched, isWatched,
onToggle, onToggle,
accentColor,
mutedColor,
}: { }: {
episode: { episode: {
id: string; id: string;
@@ -19,10 +20,9 @@ export function EpisodeRow({
}; };
isWatched: boolean; isWatched: boolean;
onToggle: () => void; onToggle: () => void;
accentColor: string;
mutedColor: string;
}) { }) {
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return ( return (
<Pressable <Pressable
onPress={onToggle} onPress={onToggle}
@@ -30,9 +30,9 @@ export function EpisodeRow({
style={{ borderBottomWidth: 0.5 }} style={{ borderBottomWidth: 0.5 }}
> >
{isWatched ? ( {isWatched ? (
<IconCircleCheckFilled size={22} color={titleAccentColor} /> <IconCircleCheckFilled size={22} color={accentColor} />
) : ( ) : (
<IconCircleDashed size={22} color={mutedFgColor} /> <IconCircleDashed size={22} color={mutedColor} />
)} )}
<View className="ml-3 flex-1"> <View className="ml-3 flex-1">
<Text <Text
@@ -115,6 +115,17 @@ export function SeasonAccordion({
}), }),
); );
const handleEpisodeToggle = useCallback(
(episodeId: string) => {
if (watchedEpisodeIds.has(episodeId)) {
unwatchEpisode.mutate({ id: episodeId });
} else {
watchEpisode.mutate({ id: episodeId });
}
},
[watchedEpisodeIds, unwatchEpisode, watchEpisode],
);
return ( return (
<View <View
className="mb-2 overflow-hidden rounded-xl border bg-card" className="mb-2 overflow-hidden rounded-xl border bg-card"
@@ -175,13 +186,9 @@ export function SeasonAccordion({
key={episode.id} key={episode.id}
episode={episode} episode={episode}
isWatched={watchedEpisodeIds.has(episode.id)} isWatched={watchedEpisodeIds.has(episode.id)}
onToggle={() => { onToggle={() => handleEpisodeToggle(episode.id)}
if (watchedEpisodeIds.has(episode.id)) { accentColor={titleAccentColor}
unwatchEpisode.mutate({ id: episode.id }); mutedColor={mutedFgColor}
} else {
watchEpisode.mutate({ id: episode.id });
}
}}
/> />
))} ))}
</Animated.View> </Animated.View>
+23 -40
View File
@@ -8,8 +8,6 @@ import {
IconPlus, IconPlus,
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Pressable, 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";
@@ -42,6 +40,13 @@ interface PosterCardProps {
userStatus?: TitleStatus | null; userStatus?: TitleStatus | null;
episodeProgress?: { watched: number; total: number } | null; episodeProgress?: { watched: number; total: number } | null;
width?: number; width?: number;
onPress: (
id: string | undefined,
tmdbId: number,
type: "movie" | "tv",
) => void;
onQuickAdd: (tmdbId: number, type: "movie" | "tv") => void;
isAdding?: boolean;
} }
export function PosterCard({ export function PosterCard({
@@ -55,8 +60,10 @@ export function PosterCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
width = 140, width = 140,
onPress,
onQuickAdd,
isAdding,
}: PosterCardProps) { }: PosterCardProps) {
const { navigate } = useRouter();
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;
const watchingColor = useCSSVariable("--color-status-watching") as string; const watchingColor = useCSSVariable("--color-status-watching") as string;
@@ -77,29 +84,6 @@ export function PosterCard({
setLocalStatus(userStatus ?? null); setLocalStatus(userStatus ?? null);
}, [userStatus]); }, [userStatus]);
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id: resolvedId }) => {
if (resolvedId) {
navigate(`/title/${resolvedId}`);
}
},
onError: () => toast.error("Failed to load title"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
setLocalStatus("watchlist");
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to add to watchlist"),
}),
);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
@@ -108,14 +92,15 @@ export function PosterCard({
], ],
})); }));
const handleResolve = useCallback(() => { const handlePressAction = useCallback(() => {
resolveMutation.mutate({ tmdbId, type }); onPress(id, tmdbId, type);
}, [tmdbId, type, resolveMutation]); }, [onPress, id, tmdbId, type]);
const handleQuickAdd = useCallback(() => { const handleQuickAddPress = useCallback(() => {
if (localStatus || quickAddMutation.isPending) return; if (localStatus || isAdding) return;
quickAddMutation.mutate({ tmdbId, type }); setLocalStatus("watchlist");
}, [localStatus, quickAddMutation, tmdbId, type]); onQuickAdd(tmdbId, type);
}, [localStatus, isAdding, onQuickAdd, tmdbId, type]);
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5; const imageHeight = width * 1.5;
@@ -148,11 +133,11 @@ export function PosterCard({
{/* Quick-add button */} {/* Quick-add button */}
{!localStatus && ( {!localStatus && (
<Pressable <Pressable
onPress={handleQuickAdd} onPress={handleQuickAddPress}
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)" }}
> >
{quickAddMutation.isPending ? ( {isAdding ? (
<IconLoader size={16} color="white" /> <IconLoader size={16} color="white" />
) : ( ) : (
<IconPlus size={16} color="white" /> <IconPlus size={16} color="white" />
@@ -248,15 +233,13 @@ 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={() => navigate(`/title/${id}`)}> <Pressable onPress={handlePressAction}>{cardContent}</Pressable>
{cardContent}
</Pressable>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{!localStatus && ( {!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAdd}> <ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} /> <ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle> <ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item> </ContextMenu.Item>
@@ -336,7 +319,7 @@ export function PosterCard({
pressed.set(withSpring(0, { damping: 15, stiffness: 300 })); pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
}) })
.onEnd(() => { .onEnd(() => {
scheduleOnRN(handleResolve); scheduleOnRN(handlePressAction);
}); });
return ( return (
@@ -13,7 +13,7 @@ const ACTION_WIDTH = 80;
function RightAction({ drag }: { drag: SharedValue<number> }) { function RightAction({ drag }: { drag: SharedValue<number> }) {
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(drag.value, [0, -ACTION_WIDTH], [0.6, 1], { const scale = interpolate(drag.get(), [0, -ACTION_WIDTH], [0.6, 1], {
extrapolateLeft: "clamp", extrapolateLeft: "clamp",
extrapolateRight: "clamp", extrapolateRight: "clamp",
}); });
+4 -8
View File
@@ -31,7 +31,7 @@ export function Switch({
const trackStyle = useAnimatedStyle(() => ({ const trackStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
progress.value, progress.get(),
[0, 1], [0, 1],
[secondaryColor, primaryColor], [secondaryColor, primaryColor],
), ),
@@ -42,11 +42,11 @@ export function Switch({
{ {
translateX: translateX:
THUMB_OFFSET + THUMB_OFFSET +
progress.value * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2), progress.get() * (TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2),
}, },
], ],
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
progress.value, progress.get(),
[0, 1], [0, 1],
[mutedFgColor, "#fff"], [mutedFgColor, "#fff"],
), ),
@@ -77,11 +77,7 @@ export function Switch({
borderRadius: THUMB_SIZE / 2, borderRadius: THUMB_SIZE / 2,
position: "absolute", position: "absolute",
top: (TRACK_HEIGHT - THUMB_SIZE) / 2, top: (TRACK_HEIGHT - THUMB_SIZE) / 2,
shadowColor: "#000", boxShadow: "0 1px 2px rgba(0, 0, 0, 0.3)",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 3,
}, },
thumbStyle, thumbStyle,
]} ]}
@@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
/**
* Provides shared press/quickAdd handlers for PosterCard lists.
* Use once per list parent instead of per-card to avoid creating
* a mutation observer for every mounted PosterCard.
*/
export function usePosterActions() {
const { navigate } = useRouter();
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
if (id) navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => {
toast.error("Failed to add to watchlist");
// Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const handlePress = useCallback(
(id: string | undefined, tmdbId: number, type: "movie" | "tv") => {
if (id) {
navigate(`/title/${id}`);
} else {
resolveMutation.mutate({ tmdbId, type });
}
},
[navigate, resolveMutation.mutate],
);
const handleQuickAdd = useCallback(
(tmdbId: number, type: "movie" | "tv") => {
quickAddMutation.mutate({ tmdbId, type });
},
[quickAddMutation.mutate],
);
const addingKey =
quickAddMutation.isPending && quickAddMutation.variables
? `${quickAddMutation.variables.tmdbId}-${quickAddMutation.variables.type}`
: null;
return { handlePress, handleQuickAdd, addingKey };
}
-5
View File
@@ -20,7 +20,6 @@
"@expo-google-fonts/geist-mono": "0.4.1", "@expo-google-fonts/geist-mono": "0.4.1",
"@expo/metro-runtime": "55.0.6", "@expo/metro-runtime": "55.0.6",
"@expo/ui": "55.0.2", "@expo/ui": "55.0.2",
"@gorhom/bottom-sheet": "5.2.8",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/contract": "catalog:", "@orpc/contract": "catalog:",
"@orpc/tanstack-query": "catalog:", "@orpc/tanstack-query": "catalog:",
@@ -702,10 +701,6 @@
"@fontsource/dm-serif-display": ["@fontsource/dm-serif-display@5.2.8", "", {}, "sha512-GYSDSlGU6vyhv9a5MwaiVNf9HCuSVpK8hEFRyG4NNDHCDeHiX7YHDAcWsaoLKKcfXLgWG9YkBkk9T3SxM4rAjQ=="], "@fontsource/dm-serif-display": ["@fontsource/dm-serif-display@5.2.8", "", {}, "sha512-GYSDSlGU6vyhv9a5MwaiVNf9HCuSVpK8hEFRyG4NNDHCDeHiX7YHDAcWsaoLKKcfXLgWG9YkBkk9T3SxM4rAjQ=="],
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],