refactor(native): extract shared RecentlyViewedRowContent and polish iOS search list

Extract the thumbnail + text content into a shared `RecentlyViewedRowContent` component so both the iOS SwiftUI list and the Android/default `RecentlyViewedRow` render identical row content without duplication.

Rework the iOS recently-viewed list header to use an `RNHostView`-backed React Native view instead of SwiftUI `Section`/`HStack`/`Button`, giving it the app's display font, a history icon, and a native `Pressable` clear button. Switch each list row to the same `RNHostView` pattern so rows render with the app's background color and consistent insets. Replace SF Symbols type icons with Tabler icons (`IconMovie`, `IconDeviceTv`, `IconUser`) to match the rest of the app.
This commit is contained in:
2026-03-16 11:54:54 -04:00
parent 32cfbc2e86
commit 753bd2d0cc
3 changed files with 141 additions and 168 deletions
@@ -1,51 +1,34 @@
import { Host, List, RNHostView, VStack } from "@expo/ui/swift-ui";
import {
Button,
Host,
HStack,
Image,
List,
Section,
Spacer,
Text,
VStack,
} from "@expo/ui/swift-ui";
import {
font,
foregroundStyle,
deleteDisabled,
listRowBackground,
listRowInsets,
listRowSeparator,
listStyle,
onTapGesture,
scrollContentBackground,
} from "@expo/ui/swift-ui/modifiers";
import { IconSearch } from "@tabler/icons-react-native";
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { Alert, View } from "react-native";
import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import type { SFSymbol } from "sf-symbols-typescript";
import { useCSSVariable } from "uniwind";
import { Text as RNText } from "@/components/ui/text";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { Text } from "@/components/ui/text";
import {
type RecentlyViewedItem,
useRecentlyViewed,
} from "@/lib/recently-viewed";
import * as Haptics from "@/utils/haptics";
const SF_SYMBOL: Record<RecentlyViewedItem["type"], SFSymbol> = {
movie: "film",
tv: "tv",
person: "person.fill",
};
const TYPE_LABEL: Record<RecentlyViewedItem["type"], string> = {
movie: "Movie",
tv: "TV Show",
person: "Person",
};
export function RecentlyViewedList() {
const { navigate } = useRouter();
const { items, removeItem, clearAll } = useRecentlyViewed();
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const [mutedForeground, primaryColor] = useCSSVariable([
"--color-muted-foreground",
"--color-primary",
]) as [string, string];
const handlePress = useCallback(
(item: RecentlyViewedItem) => {
@@ -92,9 +75,9 @@ export function RecentlyViewedList() {
className="flex-1 items-center justify-center"
>
<IconSearch size={64} color={mutedForeground} />
<RNText className="mt-3 text-[15px] text-muted-foreground">
<Text className="mt-3 text-[15px] text-muted-foreground">
Search for movies, shows, or people
</RNText>
</Text>
</Animated.View>
);
}
@@ -105,76 +88,50 @@ export function RecentlyViewedList() {
<List
modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}
>
<Section
header={
<HStack>
<Image systemName="clock.arrow.circlepath" size={14} />
<Text modifiers={[font({ weight: "semibold", size: 14 })]}>
Recently Viewed
</Text>
<Spacer />
<Button
label="Clear"
onPress={handleClear}
modifiers={[
font({ size: 14 }),
foregroundStyle({
type: "hierarchical",
style: "secondary",
}),
]}
/>
</HStack>
}
<VStack
modifiers={[
listRowBackground("clear"),
listRowInsets({ leading: 0, trailing: 0 }),
listRowSeparator("hidden"),
deleteDisabled(),
]}
>
<List.ForEach onDelete={handleDelete}>
{items.map((item) => (
<HStack
key={item.id}
spacing={12}
alignment="center"
modifiers={[onTapGesture(() => handlePress(item))]}
<RNHostView matchContents>
<View className="flex-row items-center justify-between bg-background px-2">
<View className="flex-row items-center gap-2">
<IconHistory size={20} color={primaryColor} />
<Text className="font-display text-[20px] text-foreground tracking-tight">
Recently Viewed
</Text>
</View>
<Pressable
onPress={handleClear}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Image
systemName={SF_SYMBOL[item.type]}
size={20}
modifiers={[
foregroundStyle({
type: "hierarchical",
style: "secondary",
}),
]}
/>
<VStack alignment="leading" spacing={2}>
<Text
modifiers={[
font({ weight: "medium", size: 16 }),
foregroundStyle({
type: "hierarchical",
style: "primary",
}),
]}
>
{item.title}
</Text>
<Text
modifiers={[
font({ size: 13 }),
foregroundStyle({
type: "hierarchical",
style: "secondary",
}),
]}
>
{TYPE_LABEL[item.type]}
{item.subtitle ? ` · ${item.subtitle}` : ""}
</Text>
</VStack>
<Spacer />
</HStack>
))}
</List.ForEach>
</Section>
<Text className="text-[13px] text-primary">Clear</Text>
</Pressable>
</View>
</RNHostView>
</VStack>
<List.ForEach onDelete={handleDelete}>
{items.map((item) => (
<VStack
key={item.id}
modifiers={[
listRowBackground("clear"),
listRowInsets({ leading: 0, trailing: 0 }),
onTapGesture(() => handlePress(item)),
]}
>
<RNHostView matchContents>
<View className="bg-background px-2">
<RecentlyViewedRowContent item={item} />
</View>
</RNHostView>
</VStack>
))}
</List.ForEach>
</List>
</Host>
</View>
@@ -0,0 +1,75 @@
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
const TypeIcon = {
movie: IconMovie,
tv: IconDeviceTv,
person: IconUser,
} as const;
const TypeLabel = {
movie: "Movie",
tv: "TV",
person: "Person",
} as const;
export function RecentlyViewedRowContent({
item,
}: {
item: RecentlyViewedItem;
}) {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type];
return (
<View className="flex-row items-center">
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: 48,
height: item.type === "person" ? 48 : 72,
borderRadius: item.type === "person" ? 24 : 10,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{item.imagePath ? (
<Image
source={{ uri: item.imagePath }}
className="h-full w-full"
contentFit="cover"
recyclingKey={`rv-${item.id}`}
transition={200}
/>
) : (
<View className="flex-1 items-center justify-center">
<Icon size={20} color={mutedForeground} />
</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">
{TypeLabel[item.type]}
</Text>
</View>
{item.subtitle ? (
<Text className="text-muted-foreground text-xs">
{item.subtitle}
</Text>
) : null}
</View>
</View>
</View>
);
}
@@ -1,24 +1,9 @@
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
import { memo } from "react";
import { Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Pressable } from "react-native";
import { RecentlyViewedRowContent } from "@/components/search/recently-viewed-row-content";
import { SwipeableRow } from "@/components/ui/swipeable-row";
import { Text } from "@/components/ui/text";
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
const TypeIcon = {
movie: IconMovie,
tv: IconDeviceTv,
person: IconUser,
} as const;
const TypeLabel = {
movie: "Movie",
tv: "TV",
person: "Person",
} as const;
export const RecentlyViewedRow = memo(function RecentlyViewedRow({
item,
onPress,
@@ -28,10 +13,11 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
onPress: (item: RecentlyViewedItem) => void;
onDelete: (id: string) => void;
}) {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type];
const accessibilityLabel = [item.title, TypeLabel[item.type], item.subtitle]
const accessibilityLabel = [
item.title,
item.type === "tv" ? "TV" : item.type === "movie" ? "Movie" : "Person",
item.subtitle,
]
.filter(Boolean)
.join(", ");
@@ -41,59 +27,14 @@ export const RecentlyViewedRow = memo(function RecentlyViewedRow({
onPress={() => onPress(item)}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
className="flex-row items-center bg-background px-4 py-3"
className="bg-background px-4 py-3"
style={({ pressed }) => ({
borderBottomWidth: 0.5,
borderBottomColor: "rgba(255,255,255,0.08)",
opacity: pressed ? 0.7 : 1,
})}
>
{/* Thumbnail */}
<View
className="mr-3 overflow-hidden bg-secondary"
style={{
width: item.type === "person" ? 48 : 48,
height: item.type === "person" ? 48 : 72,
borderRadius: item.type === "person" ? 24 : 10,
borderCurve: item.type === "person" ? undefined : "continuous",
}}
>
{item.imagePath ? (
<Image
source={{ uri: item.imagePath }}
className="h-full w-full"
contentFit="cover"
recyclingKey={`rv-${item.id}`}
transition={200}
/>
) : (
<View className="flex-1 items-center justify-center">
<Icon size={20} color={mutedForeground} />
</View>
)}
</View>
{/* Text content */}
<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">
{TypeLabel[item.type]}
</Text>
</View>
{item.subtitle ? (
<Text className="text-muted-foreground text-xs">
{item.subtitle}
</Text>
) : null}
</View>
</View>
<RecentlyViewedRowContent item={item} />
</Pressable>
</SwipeableRow>
);