mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
refactor(native): align title action buttons with web app design
Replace three separate status toggle buttons (Watchlist/Watching/Completed) with a single status button matching the web pattern: "+ Watchlist" when inactive, "Watching"/"Completed" when active (tap to remove). Consolidate actions into a single row with separator and star rating. Use title-accent color for theming consistency with other native components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,17 +104,6 @@ export default function TitleDetailScreen() {
|
||||
}),
|
||||
);
|
||||
|
||||
const watchAll = useMutation(
|
||||
orpc.titles.watchAll.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Marked all episodes as watched");
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
|
||||
},
|
||||
onError: () => toast.error("Failed to mark all episodes as watched"),
|
||||
}),
|
||||
);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => {
|
||||
@@ -193,10 +182,10 @@ export default function TitleDetailScreen() {
|
||||
<Skeleton width={50} height={24} borderRadius={12} />
|
||||
</View>
|
||||
{/* Actions skeleton */}
|
||||
<View className="mt-4 flex-row gap-2 px-4">
|
||||
<Skeleton width={100} height={36} borderRadius={18} />
|
||||
<Skeleton width={90} height={36} borderRadius={18} />
|
||||
<Skeleton width={105} height={36} borderRadius={18} />
|
||||
<View className="mt-4 flex-row items-center gap-3 px-4">
|
||||
<Skeleton width={110} height={36} borderRadius={8} />
|
||||
<Skeleton width={1} height={24} borderRadius={0} />
|
||||
<Skeleton width={120} height={22} borderRadius={4} />
|
||||
</View>
|
||||
{/* Overview skeleton */}
|
||||
<View className="mt-5 gap-2 px-4">
|
||||
@@ -415,57 +404,52 @@ export default function TitleDetailScreen() {
|
||||
entering={FadeInDown.duration(300).delay(200)}
|
||||
className="mt-4 px-4"
|
||||
>
|
||||
<StatusActionButton
|
||||
currentStatus={userInfo.data?.status ?? null}
|
||||
onStatusChange={(status) => {
|
||||
if (status === null) {
|
||||
updateStatus.mutate({ id, status: null });
|
||||
} else if (status === "watchlist") {
|
||||
quickAddMutation.mutate({
|
||||
tmdbId: title.tmdbId,
|
||||
type: title.type,
|
||||
});
|
||||
} else if (status === "completed" && title.type === "movie") {
|
||||
watchMovie.mutate({ id });
|
||||
} else if (status === "completed" && title.type === "tv") {
|
||||
watchAll.mutate({ id });
|
||||
} else {
|
||||
updateStatus.mutate({ id, status });
|
||||
<View className="flex-row flex-wrap items-center gap-3">
|
||||
<StatusActionButton
|
||||
currentStatus={userInfo.data?.status ?? null}
|
||||
onStatusChange={(status) => {
|
||||
if (status === "watchlist") {
|
||||
quickAddMutation.mutate({
|
||||
tmdbId: title.tmdbId,
|
||||
type: title.type,
|
||||
});
|
||||
} else {
|
||||
updateStatus.mutate({ id, status: null });
|
||||
}
|
||||
}}
|
||||
isPending={
|
||||
updateStatus.isPending ||
|
||||
quickAddMutation.isPending ||
|
||||
watchMovie.isPending
|
||||
}
|
||||
}}
|
||||
isPending={
|
||||
updateStatus.isPending ||
|
||||
quickAddMutation.isPending ||
|
||||
watchMovie.isPending ||
|
||||
watchAll.isPending
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="mt-4 flex-row items-center justify-between">
|
||||
<StarRating
|
||||
rating={userInfo.data?.rating ?? 0}
|
||||
onRate={(stars) => updateRating.mutate({ id, stars })}
|
||||
accentColor={titleAccent}
|
||||
/>
|
||||
|
||||
{title.type === "movie" && (
|
||||
<Pressable
|
||||
onPress={() => watchMovie.mutate({ id })}
|
||||
disabled={watchMovie.isPending}
|
||||
className="flex-row items-center gap-1.5 rounded-full bg-title-accent px-4 py-2"
|
||||
className="flex-row items-center gap-1.5 rounded-lg bg-title-accent px-4 py-2"
|
||||
>
|
||||
{watchMovie.isPending ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={16} color={titleAccentForeground} />
|
||||
<Text className="font-sans-medium text-[13px] text-title-accent-foreground">
|
||||
<Text className="font-sans-medium text-sm text-title-accent-foreground">
|
||||
Mark Watched
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View className="h-6 w-px bg-border/50" />
|
||||
|
||||
<StarRating
|
||||
rating={userInfo.data?.rating ?? 0}
|
||||
onRate={(stars) => updateRating.mutate({ id, stars })}
|
||||
accentColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import {
|
||||
IconBookmark,
|
||||
IconCircleCheck,
|
||||
IconPlayerPlay,
|
||||
IconCheck,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Pressable } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
const watchingStyle = {
|
||||
label: "Watching",
|
||||
Icon: IconPlayerPlayFilled,
|
||||
bgClass: "bg-title-accent/10 border-title-accent/20",
|
||||
textClass: "text-title-accent",
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
watchlist: watchingStyle,
|
||||
in_progress: watchingStyle,
|
||||
completed: {
|
||||
label: "Completed",
|
||||
Icon: IconCheck,
|
||||
bgClass: "bg-status-completed/10 border-status-completed/20",
|
||||
textClass: "text-status-completed",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function StatusActionButton({
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
@@ -19,46 +37,49 @@ export function StatusActionButton({
|
||||
onStatusChange: (status: TitleStatus | null) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const statuses: Array<{
|
||||
status: TitleStatus;
|
||||
label: string;
|
||||
Icon: typeof IconBookmark;
|
||||
}> = [
|
||||
{ status: "watchlist", label: "Watchlist", Icon: IconBookmark },
|
||||
{ status: "in_progress", label: "Watching", Icon: IconPlayerPlay },
|
||||
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
|
||||
];
|
||||
const [titleAccent, completedColor] = useCSSVariable([
|
||||
"--color-title-accent",
|
||||
"--color-status-completed",
|
||||
]) as [string, string];
|
||||
|
||||
const primaryColor = useCSSVariable("--color-title-accent") as string;
|
||||
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
const config = currentStatus
|
||||
? statusConfig[currentStatus as keyof typeof statusConfig]
|
||||
: null;
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange("watchlist");
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="flex-row items-center gap-1.5 rounded-lg border border-title-accent/20 bg-title-accent/10 px-4 py-2"
|
||||
>
|
||||
<IconPlus size={14} color={titleAccent} strokeWidth={2.5} />
|
||||
<Text className="font-sans-medium text-sm text-title-accent">
|
||||
Watchlist
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const iconColor =
|
||||
currentStatus === "completed" ? completedColor : titleAccent;
|
||||
|
||||
return (
|
||||
<View className="flex-row gap-2">
|
||||
{statuses.map(({ status, label, Icon }) => {
|
||||
const isActive = currentStatus === status;
|
||||
return (
|
||||
<Pressable
|
||||
key={status}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange(isActive ? null : status);
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
|
||||
isActive
|
||||
? "border-title-accent bg-title-accent/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
|
||||
<Text
|
||||
className={`font-sans-medium text-xs ${isActive ? "text-title-accent" : "text-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange(null);
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={`flex-row items-center gap-1.5 rounded-lg border px-4 py-2 ${config.bgClass}`}
|
||||
>
|
||||
<config.Icon size={14} color={iconColor} />
|
||||
<Text className={`font-sans-medium text-sm ${config.textClass}`}>
|
||||
{config.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user