fix(native): overhaul library screen, fix upcoming filters, improve SelectModal

Library screen:
- Replace broken bottom sheet with inline filter strip (horizontal scroll
  chips matching the web pattern) — type pills, status/genre/rating/year/
  content rating dropdown chips with SelectModal pickers
- Replace TabStack with custom layout so filter sort controls appear in
  the native header alongside the avatar
- Fix grid: 3-column layout with computed item widths, proper spacing
- Remove redundant search bar (Search tab already exists)
- Sync sort/filter state via Jotai atoms between layout and screen

Upcoming screen:
- Move filter chips into SectionList ListHeaderComponent so they scroll
  with content and don't overlap the native header
- Single horizontal scroll row with divider between type and status groups
- Status chips toggle on/off instead of redundant dual "All" buttons
- Tighten section header spacing

SelectModal:
- Add ScrollView for long option lists (genres, content ratings)
- Respect safe areas with dynamic max height from screen dimensions
- Add optional multiSelect mode (stays open, toggles checkmarks)
- Add optional clearLabel/onClear for a "Clear" button in the header
- Accept string | string[] for selection to support multi-select

Other:
- Remove Settings tab from tab bar (accessible via avatar dropdown)
- Simplify tab layout by removing unused update check query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.6 committed 2026-03-24 10:17:26 -04:00
1 parent 647784d8fc
commit 2cd1964ca8
8 files changed
+562 -191

No files matched your search

+45 -42
View File
@@ -3,7 +3,7 @@ import { IconCalendarEvent } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Pressable, RefreshControl, SectionList, View } from "react-native"; import { Pressable, RefreshControl, ScrollView, SectionList, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable, useResolveClassNames } from "uniwind"; import { useCSSVariable, useResolveClassNames } from "uniwind";
@@ -92,6 +92,41 @@ export default function UpcomingScreen() {
} }
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const filterChips = (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingTop: 10, paddingBottom: 6 }}
>
<FilterChip
label={t`All`}
isSelected={mediaType === "all"}
onPress={() => setMediaType("all")}
/>
<FilterChip
label={t`Movies`}
isSelected={mediaType === "movie"}
onPress={() => setMediaType("movie")}
/>
<FilterChip
label={t`TV`}
isSelected={mediaType === "tv"}
onPress={() => setMediaType("tv")}
/>
<View className="bg-border/30 mx-1 w-px self-stretch" />
<FilterChip
label={t`Watching`}
isSelected={statusFilter === "watching"}
onPress={() => setStatusFilter(statusFilter === "watching" ? "all" : "watching")}
/>
<FilterChip
label={t`Watchlist`}
isSelected={statusFilter === "watchlist"}
onPress={() => setStatusFilter(statusFilter === "watchlist" ? "all" : "watchlist")}
/>
</ScrollView>
);
return ( return (
<> <>
<Stack.Header <Stack.Header
@@ -107,63 +142,31 @@ export default function UpcomingScreen() {
{t`Upcoming`} {t`Upcoming`}
</Stack.Screen.Title> </Stack.Screen.Title>
<Stack.Screen.BackButton displayMode="minimal" /> <Stack.Screen.BackButton displayMode="minimal" />
<View className="flex-row flex-wrap gap-2 px-4 pt-2 pb-1">
<View className="flex-row gap-1.5">
<FilterChip
label={t`All`}
isSelected={mediaType === "all"}
onPress={() => setMediaType("all")}
/>
<FilterChip
label={t`Movies`}
isSelected={mediaType === "movie"}
onPress={() => setMediaType("movie")}
/>
<FilterChip
label={t`TV Shows`}
isSelected={mediaType === "tv"}
onPress={() => setMediaType("tv")}
/>
</View>
<View className="flex-row gap-1.5">
<FilterChip
label={t`All`}
isSelected={statusFilter === "all"}
onPress={() => setStatusFilter("all")}
/>
<FilterChip
label={t`Watching`}
isSelected={statusFilter === "watching"}
onPress={() => setStatusFilter("watching")}
/>
<FilterChip
label={t`Watchlist`}
isSelected={statusFilter === "watchlist"}
onPress={() => setStatusFilter("watchlist")}
/>
</View>
</View>
{!isPending && allItems.length === 0 ? ( {!isPending && allItems.length === 0 ? (
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
className="flex-1 items-center justify-center px-4" className="flex-1 items-center justify-center px-4"
> >
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} /> {filterChips}
<Text className="text-muted-foreground mt-4 text-center text-sm"> <View className="flex-1 items-center justify-center">
<Trans>No upcoming episodes or releases in the next 90 days.</Trans> <ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
</Text> <Text className="text-muted-foreground mt-4 text-center text-sm">
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
</Text>
</View>
</Animated.View> </Animated.View>
) : ( ) : (
<SectionList <SectionList
sections={sections} sections={sections}
keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`} keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`}
ListHeaderComponent={filterChips}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View className="px-4 py-1"> <View className="px-4 py-1">
<UpcomingRow item={item} /> <UpcomingRow item={item} />
</View> </View>
)} )}
renderSectionHeader={({ section: { title } }) => ( renderSectionHeader={({ section: { title } }) => (
<View className="bg-background px-4 pt-4 pb-1"> <View className="bg-background px-4 pt-3 pb-1">
<Text className="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> <Text className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{title} {title}
</Text> </Text>
@@ -1,8 +1,97 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { Stack } from "expo-router";
import { useAtom } from "jotai";
import { View } from "react-native";
import { useCSSVariable, useResolveClassNames } from "uniwind";
import { TabStack } from "@/components/navigation/tab-stack"; import { HeaderAvatar } from "@/components/header-avatar";
import { SortMenu } from "@/components/library/sort-menu";
import { type SortBy, librarySortByAtom, librarySortDirectionAtom } from "@/lib/library-atoms";
function LibraryHeaderRight() {
const [sortBy, setSortBy] = useAtom(librarySortByAtom);
const [sortDirection, setSortDirection] = useAtom(librarySortDirectionAtom);
return (
<View className="flex-row items-center gap-4">
<SortMenu
sortBy={sortBy}
sortDirection={sortDirection}
onSortChange={(newSort, newDir) => {
setSortBy(newSort as SortBy);
setSortDirection(newDir);
}}
/>
<HeaderAvatar />
</View>
);
}
export default function LibraryLayout() { export default function LibraryLayout() {
const { t } = useLingui(); const { t } = useLingui();
return <TabStack title={t`Library`} />; const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
const backgroundColor = useCSSVariable("--color-background") as string;
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
const headerLargeTitleStyle = useResolveClassNames("font-display text-foreground");
if (process.env.EXPO_OS === "ios") {
return (
<Stack
screenOptions={{
contentStyle,
unstable_headerRightItems: () => [
{
type: "custom" as const,
element: <LibraryHeaderRight />,
hidesSharedBackground: true,
},
],
}}
>
<Stack.Screen name="index">
<Stack.Header
transparent
blurEffect="systemChromeMaterialDark"
style={{ color: tintColor, shadowColor: "transparent" }}
largeStyle={{
backgroundColor: "transparent",
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title
large
style={headerTitleStyle as Record<string, unknown>}
largeStyle={headerLargeTitleStyle as Record<string, unknown>}
>
{t`Library`}
</Stack.Screen.Title>
</Stack.Screen>
</Stack>
);
}
return (
<Stack
screenOptions={{
contentStyle,
headerTitleAlign: "left",
headerRight: () => <LibraryHeaderRight />,
}}
>
<Stack.Screen name="index">
<Stack.Header
transparent={false}
style={{
backgroundColor,
color: tintColor,
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
{t`Library`}
</Stack.Screen.Title>
</Stack.Screen>
</Stack>
);
} }
+360 -109
View File
@@ -1,101 +1,249 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
IconAdjustmentsHorizontal, IconAdjustmentsHorizontal,
IconAlertTriangle, IconAlertTriangle,
IconBooks, IconBooks,
IconCalendarEvent,
IconCategory,
IconChecklist,
IconShieldCheck,
IconStarFilled,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, RefreshControl, View } from "react-native"; import { Pressable, RefreshControl, ScrollView, View, useWindowDimensions } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import type { LibraryFilters } from "@/components/library/filter-sheet";
import { FilterSheet } from "@/components/library/filter-sheet";
import { SortMenu } from "@/components/library/sort-menu";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { PosterCard } from "@/components/ui/poster-card"; import { PosterCard } from "@/components/ui/poster-card";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { SelectModal } from "@/components/ui/select-modal";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { useDebounce } from "@/hooks/use-debounce";
import { useTitleActions } from "@/hooks/use-title-actions"; import { useTitleActions } from "@/hooks/use-title-actions";
import {
libraryActiveFilterCountAtom,
librarySortByAtom,
librarySortDirectionAtom,
} from "@/lib/library-atoms";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity"; // ─── Grid constants ─────────────────────────────────────────────────
type SortDirection = "asc" | "desc";
const NUM_COLUMNS = 2; const NUM_COLUMNS = 3;
const HORIZONTAL_PADDING = 16; const EDGE_PADDING = 16;
const GAP = 8;
const gridContentContainerStyle = { // ─── Inline filter chip ─────────────────────────────────────────────
paddingHorizontal: HORIZONTAL_PADDING,
paddingTop: 8, function Chip({
paddingBottom: 16, label,
}; isActive,
onPress,
}: {
label: string;
isActive: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
className={`rounded-full px-3 py-1.5 ${isActive ? "bg-primary" : "bg-secondary"}`}
>
<Text
className={`font-sans text-xs font-medium ${isActive ? "text-primary-foreground" : "text-foreground"}`}
>
{label}
</Text>
</Pressable>
);
}
function DropdownChip({
label,
isActive,
onPress,
}: {
label: string;
isActive: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
accessibilityRole="button"
className={`flex-row items-center gap-1 rounded-full px-3 py-1.5 ${isActive ? "bg-primary/15 border-primary/40 border" : "bg-secondary"}`}
>
<Text
className={`font-sans text-xs font-medium ${isActive ? "text-primary" : "text-foreground"}`}
>
{label}
</Text>
<Text className={`text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}>
{"\u25BE"}
</Text>
</Pressable>
);
}
// ─── Screen ─────────────────────────────────────────────────────────
export default function LibraryScreen() { export default function LibraryScreen() {
const { t } = useLingui(); const { t } = useLingui();
const foregroundColor = useCSSVariable("--color-foreground") as string; const { width: screenWidth } = useWindowDimensions();
const itemWidth = Math.floor(
(screenWidth - 2 * EDGE_PADDING - (NUM_COLUMNS - 1) * GAP) / NUM_COLUMNS,
);
const [search, setSearch] = useState(""); // Filter state
const debouncedSearch = useDebounce(search.trim(), 300); const [type, setType] = useState<"movie" | "tv" | undefined>(undefined);
const [statuses, setStatuses] = useState<string[]>([]);
const [genreId, setGenreId] = useState<number | undefined>(undefined);
const [ratingMin, setRatingMin] = useState<number | undefined>(undefined);
const [yearMin, setYearMin] = useState<number | undefined>(undefined);
const [yearMax, setYearMax] = useState<number | undefined>(undefined);
const [contentRating, setContentRating] = useState<string | undefined>(undefined);
const [filters, setFilters] = useState<LibraryFilters>({}); // Sort state from atoms (controlled by layout header)
const [filterOpen, setFilterOpen] = useState(false); const [sortBy] = useAtom(librarySortByAtom);
const [sortBy, setSortBy] = useState<SortBy>("added_at"); const [sortDirection] = useAtom(librarySortDirectionAtom);
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => { // Modal state for dropdowns
setSortBy(newSortBy); const [genreModalOpen, setGenreModalOpen] = useState(false);
setSortDirection(newDirection); const [statusModalOpen, setStatusModalOpen] = useState(false);
}, []); const [ratingModalOpen, setRatingModalOpen] = useState(false);
const [yearModalOpen, setYearModalOpen] = useState(false);
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => { const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
setFilters(newFilters);
}, []);
// Sync active filter count to atom for header badge
const setActiveFilterCount = useSetAtom(libraryActiveFilterCountAtom);
const activeFilterCount = useMemo(() => { const activeFilterCount = useMemo(() => {
let count = 0; let count = 0;
if (filters.statuses && filters.statuses.length > 0) count++; if (statuses.length > 0) count++;
if (filters.type) count++; if (type) count++;
if (filters.genreId !== undefined) count++; if (genreId !== undefined) count++;
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) count++; if (ratingMin !== undefined) count++;
if (filters.yearMin !== undefined || filters.yearMax !== undefined) count++; if (yearMin !== undefined || yearMax !== undefined) count++;
if (filters.contentRating) count++; if (contentRating) count++;
if (filters.onMyServices) count++;
return count; return count;
}, [filters]); }, [statuses, type, genreId, ratingMin, yearMin, yearMax, contentRating]);
useEffect(() => {
setActiveFilterCount(activeFilterCount);
}, [activeFilterCount, setActiveFilterCount]);
// Genre data for filter
const { data: genreData } = useQuery(orpc.library.genres.queryOptions());
const genreOptions = useMemo(
() => [
{ value: "", label: t`All genres` },
...(genreData?.genres.map((g) => ({ value: String(g.id), label: g.name })) ?? []),
],
[genreData, t],
);
const statusOptions = useMemo(
() => [
{ value: "in_watchlist", label: t`Watchlist` },
{ value: "watching", label: t`Watching` },
{ value: "caught_up", label: t`Caught Up` },
{ value: "completed", label: t`Completed` },
],
[t],
);
const ratingOptions = useMemo(
() => [
{ value: "", label: t`Any` },
{ value: "1", label: "1\u2605+" },
{ value: "2", label: "2\u2605+" },
{ value: "3", label: "3\u2605+" },
{ value: "4", label: "4\u2605+" },
{ value: "5", label: "5\u2605" },
],
[t],
);
const yearOptions = useMemo(
() => [
{ value: "", label: t`Any year` },
{ value: "2020", label: "2020s" },
{ value: "2010", label: "2010s" },
{ value: "2000", label: "2000s" },
{ value: "1990", label: "1990s" },
{ value: "1980", label: "1980s" },
{ value: "older", label: t`Pre-1980` },
],
[t],
);
const contentRatingOptions = useMemo(
() => [
{ value: "", label: t`All` },
...["G", "PG", "PG-13", "R", "NC-17", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA"].map(
(r) => ({ value: r, label: r }),
),
],
[t],
);
// Derived labels for dropdown chips
const statusLabel = useMemo(() => {
if (statuses.length === 0) return t`Status`;
if (statuses.length === 1)
return statusOptions.find((s) => s.value === statuses[0])?.label ?? t`Status`;
return `${statuses.length} statuses`;
}, [statuses, statusOptions, t]);
const genreLabel = genreData?.genres.find((g) => g.id === genreId)?.name ?? t`Genre`;
const ratingLabel = ratingMin ? `${ratingMin}\u2605+` : t`Rating`;
const yearLabel = useMemo(() => {
if (yearMin && yearMax) {
const decade = yearOptions.find((y) => y.value === String(yearMin));
return decade?.label ?? t`Year`;
}
if (yearMax === 1979) return t`Pre-1980`;
return t`Year`;
}, [yearMin, yearMax, yearOptions, t]);
const contentRatingLabel = contentRating ?? t`Age`;
// Query
const libraryQuery = useInfiniteQuery({ const libraryQuery = useInfiniteQuery({
...orpc.library.list.infiniteOptions({ ...orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ input: (pageParam: number) => ({
search: debouncedSearch.length > 0 ? debouncedSearch : undefined,
statuses: statuses:
filters.statuses && filters.statuses.length > 0 statuses.length > 0
? (filters.statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[]) ? (statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
: undefined, : undefined,
type: filters.type as "movie" | "tv" | undefined, type,
genreId: filters.genreId, genreId,
ratingMin: filters.ratingMin, ratingMin,
ratingMax: filters.ratingMax, yearMin,
yearMin: filters.yearMin, yearMax,
yearMax: filters.yearMax, contentRating,
contentRating: filters.contentRating,
onMyServices: filters.onMyServices,
sortBy, sortBy,
sortDirection, sortDirection,
page: pageParam, page: pageParam,
limit: 20, limit: 30,
}), }),
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 20, maxPages: 10,
}), }),
enabled: true,
}); });
const { quickAdd } = useTitleActions(); const { quickAdd } = useTitleActions();
@@ -108,7 +256,6 @@ export default function LibraryScreen() {
); );
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage; const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
const onRefresh = useCallback(() => { const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.library.key() }); queryClient.invalidateQueries({ queryKey: orpc.library.key() });
}, []); }, []);
@@ -117,7 +264,7 @@ export default function LibraryScreen() {
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: LibraryItem }) => ( ({ item }: { item: LibraryItem }) => (
<View className="flex-1 px-1.5 pb-3"> <View style={{ flex: 1, paddingHorizontal: GAP / 2, paddingBottom: GAP }}>
<PosterCard <PosterCard
id={item.id} id={item.id}
title={item.title} title={item.title}
@@ -127,56 +274,103 @@ export default function LibraryScreen() {
releaseDate={item.releaseDate ?? item.firstAirDate} releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage} voteAverage={item.voteAverage}
userStatus={item.userStatus} userStatus={item.userStatus}
width={itemWidth}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingId === item.id} isAdding={addingId === item.id}
/> />
</View> </View>
), ),
[handleQuickAdd, addingId], [handleQuickAdd, addingId, itemWidth],
); );
const keyExtractor = useCallback((item: LibraryItem) => item.id, []); const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
const filterButton = ( function clearAll() {
<View className="flex-row items-center gap-4"> setType(undefined);
<SortMenu sortBy={sortBy} sortDirection={sortDirection} onSortChange={handleSortChange} /> setStatuses([]);
<Pressable setGenreId(undefined);
onPress={() => setFilterOpen(true)} setRatingMin(undefined);
accessibilityRole="button" setYearMin(undefined);
accessibilityLabel={t`Filters`} setYearMax(undefined);
hitSlop={8} setContentRating(undefined);
> }
<View className="flex-row items-center">
<ScaledIcon icon={IconAdjustmentsHorizontal} size={22} color={foregroundColor} /> function handleYearSelect(value: string) {
{activeFilterCount > 0 && ( if (!value) {
<View className="bg-primary -mt-2 -ml-1.5 size-4 items-center justify-center rounded-full"> setYearMin(undefined);
<Text className="text-primary-foreground text-[10px] font-bold"> setYearMax(undefined);
{activeFilterCount} } else if (value === "older") {
</Text> setYearMin(undefined);
</View> setYearMax(1979);
)} } else {
</View> const min = Number(value);
</Pressable> setYearMin(min);
</View> setYearMax(min + 9);
}
}
// ─── Filter strip ───────────────────────────────────────────────
const filterStrip = (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 6, paddingTop: 6, paddingBottom: 16 }}
>
{/* Type pills */}
<Chip label={t`All`} isActive={!type} onPress={() => setType(undefined)} />
<Chip
label={t`Movies`}
isActive={type === "movie"}
onPress={() => setType(type === "movie" ? undefined : "movie")}
/>
<Chip
label={t`TV`}
isActive={type === "tv"}
onPress={() => setType(type === "tv" ? undefined : "tv")}
/>
<View className="bg-border/30 mx-1 w-px self-stretch" />
{/* Dropdown chips */}
<DropdownChip
label={statusLabel}
isActive={statuses.length > 0}
onPress={() => setStatusModalOpen(true)}
/>
<DropdownChip
label={genreLabel}
isActive={genreId !== undefined}
onPress={() => setGenreModalOpen(true)}
/>
<DropdownChip
label={ratingLabel}
isActive={ratingMin !== undefined}
onPress={() => setRatingModalOpen(true)}
/>
<DropdownChip
label={yearLabel}
isActive={yearMin !== undefined || yearMax !== undefined}
onPress={() => setYearModalOpen(true)}
/>
<DropdownChip
label={contentRatingLabel}
isActive={!!contentRating}
onPress={() => setContentRatingModalOpen(true)}
/>
{activeFilterCount > 0 && (
<Pressable onPress={clearAll} className="justify-center px-2">
<Text className="text-muted-foreground text-xs">{t`Clear`}</Text>
</Pressable>
)}
</ScrollView>
); );
// ─── Render ───────────────────────────────────────────────────────
return ( return (
<View collapsable={false} className="bg-background flex-1"> <View collapsable={false} className="bg-background flex-1">
<Stack.Screen
options={{
headerRight: () => filterButton,
}}
/>
<Stack.SearchBar
placeholder={t`Search your library...`}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
onCancelButtonPress={() => setSearch("")}
onClose={() => setSearch("")}
hideWhenScrolling={false}
placement={process.env.EXPO_OS === "ios" ? "integrated" : undefined}
allowToolbarIntegration={process.env.EXPO_OS === "ios" ? true : undefined}
/>
{libraryQuery.isPending ? ( {libraryQuery.isPending ? (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
<Spinner colorClassName="accent-primary" /> <Spinner colorClassName="accent-primary" />
@@ -191,19 +385,14 @@ export default function LibraryScreen() {
/> />
) : allItems.length === 0 ? ( ) : allItems.length === 0 ? (
<Animated.View entering={FadeIn.duration(300)} className="flex-1"> <Animated.View entering={FadeIn.duration(300)} className="flex-1">
{debouncedSearch.length > 0 ? ( {filterStrip}
<View className="flex-1 items-center justify-center"> {activeFilterCount > 0 ? (
<Text className="text-muted-foreground text-base">
<Trans>No results for "{debouncedSearch}"</Trans>
</Text>
</View>
) : activeFilterCount > 0 ? (
<EmptyState <EmptyState
icon={IconAdjustmentsHorizontal} icon={IconAdjustmentsHorizontal}
title={t`No matching titles`} title={t`No matching titles`}
description={t`Try adjusting your filters`} description={t`Try adjusting your filters`}
actionLabel={t`Clear filters`} actionLabel={t`Clear filters`}
onAction={() => setFilters({})} onAction={clearAll}
/> />
) : ( ) : (
<EmptyState <EmptyState
@@ -219,8 +408,12 @@ export default function LibraryScreen() {
numColumns={NUM_COLUMNS} numColumns={NUM_COLUMNS}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderItem} renderItem={renderItem}
ListHeaderComponent={filterStrip}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={gridContentContainerStyle} contentContainerStyle={{
paddingHorizontal: EDGE_PADDING - GAP / 2,
paddingBottom: 16,
}}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />} refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
onEndReached={() => { onEndReached={() => {
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) { if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
@@ -238,11 +431,69 @@ export default function LibraryScreen() {
/> />
)} )}
<FilterSheet {/* SelectModals for dropdown chips */}
open={filterOpen} <SelectModal
onOpenChange={setFilterOpen} label={t`Status`}
filters={filters} icon={IconChecklist}
onApply={handleApplyFilters} selection={statuses}
options={statusOptions}
open={statusModalOpen}
onOpenChange={setStatusModalOpen}
multiSelect
clearLabel={statuses.length > 0 ? t`Clear` : undefined}
onClear={() => setStatuses([])}
onSelect={(value) =>
setStatuses((prev) =>
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value],
)
}
/>
<SelectModal
label={t`Genre`}
icon={IconCategory}
selection={genreId !== undefined ? String(genreId) : ""}
options={genreOptions}
open={genreModalOpen}
onOpenChange={setGenreModalOpen}
clearLabel={genreId !== undefined ? t`Clear` : undefined}
onClear={() => setGenreId(undefined)}
onSelect={(value) => setGenreId(value ? Number(value) : undefined)}
/>
<SelectModal
label={t`Rating`}
icon={IconStarFilled}
selection={ratingMin !== undefined ? String(ratingMin) : ""}
options={ratingOptions}
open={ratingModalOpen}
onOpenChange={setRatingModalOpen}
clearLabel={ratingMin !== undefined ? t`Clear` : undefined}
onClear={() => setRatingMin(undefined)}
onSelect={(value) => setRatingMin(value ? Number(value) : undefined)}
/>
<SelectModal
label={t`Year`}
icon={IconCalendarEvent}
selection={yearMin ? String(yearMin) : yearMax === 1979 ? "older" : ""}
options={yearOptions}
open={yearModalOpen}
onOpenChange={setYearModalOpen}
clearLabel={yearMin !== undefined || yearMax !== undefined ? t`Clear` : undefined}
onClear={() => {
setYearMin(undefined);
setYearMax(undefined);
}}
onSelect={handleYearSelect}
/>
<SelectModal
label={t`Content Rating`}
icon={IconShieldCheck}
selection={contentRating ?? ""}
options={contentRatingOptions}
open={contentRatingModalOpen}
onOpenChange={setContentRatingModalOpen}
clearLabel={contentRating ? t`Clear` : undefined}
onClear={() => setContentRating(undefined)}
onSelect={(value) => setContentRating(value || undefined)}
/> />
</View> </View>
); );
+1 -16
View File
@@ -1,24 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { NativeTabBar } from "@/components/navigation/native-tab-bar"; import { NativeTabBar } from "@/components/navigation/native-tab-bar";
import { orpc } from "@/lib/orpc";
import { authClient } from "@/lib/server";
export const unstable_settings = { export const unstable_settings = {
initialRouteName: "(home)", initialRouteName: "(home)",
}; };
export default function TabLayout() { export default function TabLayout() {
const { data: session } = authClient.useSession(); return <NativeTabBar />;
const isAdmin = session?.user?.role === "admin";
const updateCheck = useQuery({
...orpc.admin.updateCheck.queryOptions(),
enabled: isAdmin,
staleTime: 10 * 60 * 1000,
});
const showSettingsBadge = !!updateCheck.data?.updateCheck?.updateAvailable;
return <NativeTabBar showSettingsBadge={showSettingsBadge} />;
} }
@@ -30,7 +30,7 @@ export function UpcomingSection() {
onSeeAll={() => push("/upcoming")} onSeeAll={() => push("/upcoming")}
/> />
</View> </View>
<View className="gap-2 px-4"> <View className="gap-1 px-4">
{items.map((item, i) => ( {items.map((item, i) => (
<UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} /> <UpcomingRow key={`${item.titleId}-${item.date}-${i}`} item={item} />
))} ))}
@@ -3,7 +3,7 @@ import * as Haptics from "expo-haptics";
import { NativeTabs } from "expo-router/unstable-native-tabs"; import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) { export function NativeTabBar() {
const { t } = useLingui(); const { t } = useLingui();
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;
@@ -39,7 +39,6 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge> <NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" /> <NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge> <NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
+50 -19
View File
@@ -1,5 +1,6 @@
import { type Icon, IconCheck } from "@tabler/icons-react-native"; import { type Icon, IconCheck } from "@tabler/icons-react-native";
import { Modal, Pressable, View } from "react-native"; import { Modal, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@@ -17,7 +18,7 @@ export interface SelectModalProps {
/** Icon displayed in the modal title */ /** Icon displayed in the modal title */
icon?: Icon; icon?: Icon;
/** Currently selected value */ /** Currently selected value */
selection: string; selection: string | string[];
/** Available options */ /** Available options */
options: SelectModalOption[]; options: SelectModalOption[];
/** Whether the modal is open */ /** Whether the modal is open */
@@ -26,6 +27,12 @@ export interface SelectModalProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Called when an option is selected */ /** Called when an option is selected */
onSelect: (value: string) => void; onSelect: (value: string) => void;
/** When true, tapping an option toggles it without closing the modal */
multiSelect?: boolean;
/** Optional clear button label — shown in the header when provided */
clearLabel?: string;
/** Called when the clear button is pressed */
onClear?: () => void;
} }
export function SelectModal({ export function SelectModal({
@@ -36,9 +43,18 @@ export function SelectModal({
open, open,
onOpenChange, onOpenChange,
onSelect, onSelect,
multiSelect,
clearLabel,
onClear,
}: SelectModalProps) { }: SelectModalProps) {
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 { top: safeTop, bottom: safeBottom } = useSafeAreaInsets();
const { height: screenHeight } = useWindowDimensions();
const maxCardHeight = screenHeight - safeTop - safeBottom - 64;
const isSelected = (value: string) =>
Array.isArray(selection) ? selection.includes(value) : selection === value;
return ( return (
<Modal <Modal
@@ -49,36 +65,51 @@ export function SelectModal({
> >
<Pressable <Pressable
className="flex-1 items-center justify-center bg-black/60" className="flex-1 items-center justify-center bg-black/60"
style={{ paddingTop: safeTop + 16, paddingBottom: safeBottom + 16 }}
onPress={() => onOpenChange(false)} onPress={() => onOpenChange(false)}
> >
<Pressable <Pressable
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl" className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
style={{ maxHeight: maxCardHeight }}
onPress={(e) => e.stopPropagation()} onPress={(e) => e.stopPropagation()}
> >
<View className="border-border/50 flex-row items-center border-b px-5 py-4"> <View className="border-border/50 flex-row items-center border-b p-4">
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />} {Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text> <Text className="text-foreground ml-1.5 flex-1 text-base font-medium">{label}</Text>
</View> {clearLabel && onClear && (
{options.map((option) => {
const isSelected = option.value === selection;
return (
<Pressable <Pressable
key={option.value}
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
onPress={() => { onPress={() => {
onClear();
onOpenChange(false); onOpenChange(false);
onSelect(option.value);
}} }}
hitSlop={8}
> >
<Text <Text className="text-muted-foreground text-sm">{clearLabel}</Text>
className={`flex-1 text-base ${isSelected ? "text-primary font-medium" : "text-foreground"}`}
>
{option.label}
</Text>
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
</Pressable> </Pressable>
); )}
})} </View>
<ScrollView bounces={false}>
{options.map((option) => {
const selected = isSelected(option.value);
return (
<Pressable
key={option.value}
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
onPress={() => {
onSelect(option.value);
if (!multiSelect) onOpenChange(false);
}}
>
<Text
className={`flex-1 text-base ${selected ? "text-primary font-medium" : "text-foreground"}`}
>
{option.label}
</Text>
{selected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
</Pressable>
);
})}
</ScrollView>
</Pressable> </Pressable>
</Pressable> </Pressable>
</Modal> </Modal>
+13
View File
@@ -0,0 +1,13 @@
import { atom } from "jotai";
export type SortBy =
| "added_at"
| "title"
| "release_date"
| "user_rating"
| "vote_average"
| "popularity";
export const libraryActiveFilterCountAtom = atom(0);
export const librarySortByAtom = atom<SortBy>("added_at");
export const librarySortDirectionAtom = atom<"asc" | "desc">("desc");