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:
2026-03-24 10:17:26 -04:00
co-authored by Claude Opus 4.6
parent 647784d8fc
commit 2cd1964ca8
8 changed files with 562 additions and 191 deletions
+45 -42
View File
@@ -3,7 +3,7 @@ import { IconCalendarEvent } from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
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 { useCSSVariable, useResolveClassNames } from "uniwind";
@@ -92,6 +92,41 @@ export default function UpcomingScreen() {
}
}, [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 (
<>
<Stack.Header
@@ -107,63 +142,31 @@ export default function UpcomingScreen() {
{t`Upcoming`}
</Stack.Screen.Title>
<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 ? (
<Animated.View
entering={FadeInDown.duration(300)}
className="flex-1 items-center justify-center px-4"
>
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
<Text className="text-muted-foreground mt-4 text-center text-sm">
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
</Text>
{filterChips}
<View className="flex-1 items-center justify-center">
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
<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>
) : (
<SectionList
sections={sections}
keyExtractor={(item, i) => `${item.titleId}-${item.date}-${i}`}
ListHeaderComponent={filterChips}
renderItem={({ item }) => (
<View className="px-4 py-1">
<UpcomingRow item={item} />
</View>
)}
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">
{title}
</Text>
@@ -1,8 +1,97 @@
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() {
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 {
IconAdjustmentsHorizontal,
IconAlertTriangle,
IconBooks,
IconCalendarEvent,
IconCategory,
IconChecklist,
IconShieldCheck,
IconStarFilled,
} from "@tabler/icons-react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback, useMemo, useState } from "react";
import { Pressable, RefreshControl, View } from "react-native";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, RefreshControl, ScrollView, View, useWindowDimensions } from "react-native";
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 { 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 { Text } from "@/components/ui/text";
import { useDebounce } from "@/hooks/use-debounce";
import { useTitleActions } from "@/hooks/use-title-actions";
import {
libraryActiveFilterCountAtom,
librarySortByAtom,
librarySortDirectionAtom,
} from "@/lib/library-atoms";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics";
type SortBy = "added_at" | "title" | "release_date" | "user_rating" | "vote_average" | "popularity";
type SortDirection = "asc" | "desc";
// ─── Grid constants ─────────────────────────────────────────────────
const NUM_COLUMNS = 2;
const HORIZONTAL_PADDING = 16;
const NUM_COLUMNS = 3;
const EDGE_PADDING = 16;
const GAP = 8;
const gridContentContainerStyle = {
paddingHorizontal: HORIZONTAL_PADDING,
paddingTop: 8,
paddingBottom: 16,
};
// ─── Inline filter chip ─────────────────────────────────────────────
function Chip({
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() {
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("");
const debouncedSearch = useDebounce(search.trim(), 300);
// Filter state
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>({});
const [filterOpen, setFilterOpen] = useState(false);
const [sortBy, setSortBy] = useState<SortBy>("added_at");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
// Sort state from atoms (controlled by layout header)
const [sortBy] = useAtom(librarySortByAtom);
const [sortDirection] = useAtom(librarySortDirectionAtom);
const handleSortChange = useCallback((newSortBy: SortBy, newDirection: SortDirection) => {
setSortBy(newSortBy);
setSortDirection(newDirection);
}, []);
const handleApplyFilters = useCallback((newFilters: LibraryFilters) => {
setFilters(newFilters);
}, []);
// Modal state for dropdowns
const [genreModalOpen, setGenreModalOpen] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const [ratingModalOpen, setRatingModalOpen] = useState(false);
const [yearModalOpen, setYearModalOpen] = useState(false);
const [contentRatingModalOpen, setContentRatingModalOpen] = useState(false);
// Sync active filter count to atom for header badge
const setActiveFilterCount = useSetAtom(libraryActiveFilterCountAtom);
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.statuses && filters.statuses.length > 0) count++;
if (filters.type) count++;
if (filters.genreId !== undefined) count++;
if (filters.ratingMin !== undefined || filters.ratingMax !== undefined) count++;
if (filters.yearMin !== undefined || filters.yearMax !== undefined) count++;
if (filters.contentRating) count++;
if (filters.onMyServices) count++;
if (statuses.length > 0) count++;
if (type) count++;
if (genreId !== undefined) count++;
if (ratingMin !== undefined) count++;
if (yearMin !== undefined || yearMax !== undefined) count++;
if (contentRating) 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({
...orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({
search: debouncedSearch.length > 0 ? debouncedSearch : undefined,
statuses:
filters.statuses && filters.statuses.length > 0
? (filters.statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
statuses.length > 0
? (statuses as ("in_watchlist" | "watching" | "caught_up" | "completed")[])
: undefined,
type: filters.type as "movie" | "tv" | undefined,
genreId: filters.genreId,
ratingMin: filters.ratingMin,
ratingMax: filters.ratingMax,
yearMin: filters.yearMin,
yearMax: filters.yearMax,
contentRating: filters.contentRating,
onMyServices: filters.onMyServices,
type,
genreId,
ratingMin,
yearMin,
yearMax,
contentRating,
sortBy,
sortDirection,
page: pageParam,
limit: 20,
limit: 30,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 20,
maxPages: 10,
}),
enabled: true,
});
const { quickAdd } = useTitleActions();
@@ -108,7 +256,6 @@ export default function LibraryScreen() {
);
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
}, []);
@@ -117,7 +264,7 @@ export default function LibraryScreen() {
const renderItem = useCallback(
({ item }: { item: LibraryItem }) => (
<View className="flex-1 px-1.5 pb-3">
<View style={{ flex: 1, paddingHorizontal: GAP / 2, paddingBottom: GAP }}>
<PosterCard
id={item.id}
title={item.title}
@@ -127,56 +274,103 @@ export default function LibraryScreen() {
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
width={itemWidth}
onQuickAdd={handleQuickAdd}
isAdding={addingId === item.id}
/>
</View>
),
[handleQuickAdd, addingId],
[handleQuickAdd, addingId, itemWidth],
);
const keyExtractor = useCallback((item: LibraryItem) => item.id, []);
const filterButton = (
<View className="flex-row items-center gap-4">
<SortMenu sortBy={sortBy} sortDirection={sortDirection} onSortChange={handleSortChange} />
<Pressable
onPress={() => setFilterOpen(true)}
accessibilityRole="button"
accessibilityLabel={t`Filters`}
hitSlop={8}
>
<View className="flex-row items-center">
<ScaledIcon icon={IconAdjustmentsHorizontal} size={22} color={foregroundColor} />
{activeFilterCount > 0 && (
<View className="bg-primary -mt-2 -ml-1.5 size-4 items-center justify-center rounded-full">
<Text className="text-primary-foreground text-[10px] font-bold">
{activeFilterCount}
</Text>
</View>
)}
</View>
</Pressable>
</View>
function clearAll() {
setType(undefined);
setStatuses([]);
setGenreId(undefined);
setRatingMin(undefined);
setYearMin(undefined);
setYearMax(undefined);
setContentRating(undefined);
}
function handleYearSelect(value: string) {
if (!value) {
setYearMin(undefined);
setYearMax(undefined);
} else if (value === "older") {
setYearMin(undefined);
setYearMax(1979);
} else {
const min = Number(value);
setYearMin(min);
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 (
<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 ? (
<View className="flex-1 items-center justify-center">
<Spinner colorClassName="accent-primary" />
@@ -191,19 +385,14 @@ export default function LibraryScreen() {
/>
) : allItems.length === 0 ? (
<Animated.View entering={FadeIn.duration(300)} className="flex-1">
{debouncedSearch.length > 0 ? (
<View className="flex-1 items-center justify-center">
<Text className="text-muted-foreground text-base">
<Trans>No results for "{debouncedSearch}"</Trans>
</Text>
</View>
) : activeFilterCount > 0 ? (
{filterStrip}
{activeFilterCount > 0 ? (
<EmptyState
icon={IconAdjustmentsHorizontal}
title={t`No matching titles`}
description={t`Try adjusting your filters`}
actionLabel={t`Clear filters`}
onAction={() => setFilters({})}
onAction={clearAll}
/>
) : (
<EmptyState
@@ -219,8 +408,12 @@ export default function LibraryScreen() {
numColumns={NUM_COLUMNS}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListHeaderComponent={filterStrip}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={gridContentContainerStyle}
contentContainerStyle={{
paddingHorizontal: EDGE_PADDING - GAP / 2,
paddingBottom: 16,
}}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
onEndReached={() => {
if (libraryQuery.hasNextPage && !libraryQuery.isFetchingNextPage) {
@@ -238,11 +431,69 @@ export default function LibraryScreen() {
/>
)}
<FilterSheet
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
onApply={handleApplyFilters}
{/* SelectModals for dropdown chips */}
<SelectModal
label={t`Status`}
icon={IconChecklist}
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>
);
+1 -16
View File
@@ -1,24 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { NativeTabBar } from "@/components/navigation/native-tab-bar";
import { orpc } from "@/lib/orpc";
import { authClient } from "@/lib/server";
export const unstable_settings = {
initialRouteName: "(home)",
};
export default function TabLayout() {
const { data: session } = authClient.useSession();
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} />;
return <NativeTabBar />;
}
@@ -30,7 +30,7 @@ export function UpcomingSection() {
onSeeAll={() => push("/upcoming")}
/>
</View>
<View className="gap-2 px-4">
<View className="gap-1 px-4">
{items.map((item, i) => (
<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 { useCSSVariable } from "uniwind";
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
export function NativeTabBar() {
const { t } = useLingui();
const mutedFgColor = useCSSVariable("--color-muted-foreground") 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.Label>{t`Settings`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
<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 { 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 { Text } from "@/components/ui/text";
@@ -17,7 +18,7 @@ export interface SelectModalProps {
/** Icon displayed in the modal title */
icon?: Icon;
/** Currently selected value */
selection: string;
selection: string | string[];
/** Available options */
options: SelectModalOption[];
/** Whether the modal is open */
@@ -26,6 +27,12 @@ export interface SelectModalProps {
onOpenChange: (open: boolean) => void;
/** Called when an option is selected */
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({
@@ -36,9 +43,18 @@ export function SelectModal({
open,
onOpenChange,
onSelect,
multiSelect,
clearLabel,
onClear,
}: SelectModalProps) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") 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 (
<Modal
@@ -49,36 +65,51 @@ export function SelectModal({
>
<Pressable
className="flex-1 items-center justify-center bg-black/60"
style={{ paddingTop: safeTop + 16, paddingBottom: safeBottom + 16 }}
onPress={() => onOpenChange(false)}
>
<Pressable
className="bg-card mx-8 w-full max-w-sm overflow-hidden rounded-2xl"
style={{ maxHeight: maxCardHeight }}
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} />}
<Text className="text-foreground ml-1.5 text-base font-medium">{label}</Text>
</View>
{options.map((option) => {
const isSelected = option.value === selection;
return (
<Text className="text-foreground ml-1.5 flex-1 text-base font-medium">{label}</Text>
{clearLabel && onClear && (
<Pressable
key={option.value}
className="active:bg-primary/5 flex-row items-center px-5 py-3.5"
onPress={() => {
onClear();
onOpenChange(false);
onSelect(option.value);
}}
hitSlop={8}
>
<Text
className={`flex-1 text-base ${isSelected ? "text-primary font-medium" : "text-foreground"}`}
>
{option.label}
</Text>
{isSelected && <IconCheck size={18} color={primaryColor} strokeWidth={2} />}
<Text className="text-muted-foreground text-sm">{clearLabel}</Text>
</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>
</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");