mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 22:55:42 -04:00
219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import { IconAlertTriangle, 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, ScrollView, SectionList, View } from "react-native";
|
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
|
import { useCSSVariable, useResolveClassNames } from "uniwind";
|
|
|
|
import { UpcomingRow } from "@/components/dashboard/upcoming-row";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
|
import { Text } from "@/components/ui/text";
|
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
|
import { orpc } from "@/lib/orpc";
|
|
import { queryClient } from "@/lib/query-client";
|
|
import * as Haptics from "@/utils/haptics";
|
|
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
|
|
|
const contentContainerStyle = { paddingBottom: 24 };
|
|
|
|
function FilterChip({
|
|
label,
|
|
isSelected,
|
|
onPress,
|
|
}: {
|
|
label: string;
|
|
isSelected: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={() => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
onPress();
|
|
}}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: isSelected }}
|
|
className={`rounded-full px-3 py-1.5 ${isSelected ? "bg-primary" : "bg-secondary"}`}
|
|
>
|
|
<Text
|
|
className={`font-sans text-xs font-medium ${isSelected ? "text-primary-foreground" : "text-foreground"}`}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export default function UpcomingScreen() {
|
|
const { t } = useLingui();
|
|
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
|
|
const tintColor = useCSSVariable("--color-primary") as string;
|
|
const backgroundColor = useCSSVariable("--color-background") as string;
|
|
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
|
|
|
|
const [view, setView] = useState<"upcoming" | "recent">("upcoming");
|
|
const isRecent = view === "recent";
|
|
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
|
|
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
|
|
|
|
const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
|
useInfiniteQuery(
|
|
orpc.library.upcoming.infiniteOptions({
|
|
input: (pageParam: string | undefined) => ({
|
|
days: 90,
|
|
limit: 20,
|
|
cursor: pageParam,
|
|
direction: isRecent ? "recent" : undefined,
|
|
mediaType: !isRecent && mediaType !== "all" ? mediaType : undefined,
|
|
statusFilter: !isRecent && statusFilter !== "all" ? [statusFilter] : undefined,
|
|
}),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
}),
|
|
);
|
|
|
|
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
|
const sections = useMemo(
|
|
() =>
|
|
groupByDateBucket(allItems, { past: view === "recent" }).map((b) => ({
|
|
key: b.key,
|
|
title: b.label,
|
|
data: b.items,
|
|
})),
|
|
[allItems, view],
|
|
);
|
|
|
|
const refreshUpcoming = useCallback(
|
|
() => queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() }),
|
|
[],
|
|
);
|
|
const { refreshing, onRefresh } = usePullToRefresh(refreshUpcoming);
|
|
|
|
const onEndReached = useCallback(() => {
|
|
if (hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
const filterChips = (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingTop: 10, paddingBottom: 6 }}
|
|
>
|
|
<FilterChip
|
|
label={t`Upcoming`}
|
|
isSelected={view === "upcoming"}
|
|
onPress={() => setView("upcoming")}
|
|
/>
|
|
<FilterChip
|
|
label={t`Recently aired`}
|
|
isSelected={view === "recent"}
|
|
onPress={() => setView("recent")}
|
|
/>
|
|
<View className="bg-border/30 mx-1 w-px self-stretch" />
|
|
{!isRecent && (
|
|
<>
|
|
<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>
|
|
);
|
|
|
|
const isIOS = process.env.EXPO_OS === "ios";
|
|
|
|
return (
|
|
<>
|
|
<Stack.Header
|
|
transparent={isIOS}
|
|
blurEffect={isIOS ? "systemChromeMaterialDark" : undefined}
|
|
style={{
|
|
color: tintColor,
|
|
shadowColor: "transparent",
|
|
backgroundColor: isIOS ? undefined : backgroundColor,
|
|
}}
|
|
/>
|
|
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
|
|
{t`Upcoming`}
|
|
</Stack.Screen.Title>
|
|
<Stack.Screen.BackButton displayMode="minimal" />
|
|
<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-3 pb-1">
|
|
<Text className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
stickySectionHeadersEnabled
|
|
contentContainerStyle={contentContainerStyle}
|
|
contentInsetAdjustmentBehavior="automatic"
|
|
onEndReached={onEndReached}
|
|
onEndReachedThreshold={0.5}
|
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
|
ListEmptyComponent={
|
|
isPending ? null : isError && allItems.length === 0 ? (
|
|
<EmptyState
|
|
icon={IconAlertTriangle}
|
|
title={t`Something went wrong`}
|
|
description={t`Couldn't load upcoming releases`}
|
|
actionLabel={t`Retry`}
|
|
onAction={() => refetch()}
|
|
/>
|
|
) : (
|
|
<Animated.View
|
|
entering={FadeInDown.duration(300)}
|
|
className="items-center justify-center px-4 py-16"
|
|
>
|
|
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
|
<Text className="text-muted-foreground mt-4 text-center text-sm">
|
|
{isRecent ? (
|
|
<Trans>You're all caught up on the last 90 days.</Trans>
|
|
) : (
|
|
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
|
)}
|
|
</Text>
|
|
</Animated.View>
|
|
)
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|