feat(native): add HeroBanner and OfflineBanner components with haptic feedback

- Introduced HeroBanner component for displaying movie or TV show details with animated press effects and navigation.
- Added OfflineBanner component to notify users of network connectivity issues, utilizing glass effect when available.
- Implemented haptic feedback utilities for Android to enhance user interactions.
This commit is contained in:
2026-03-13 17:43:46 -04:00
parent d63fb7bac2
commit c037a64028
4 changed files with 297 additions and 43 deletions
@@ -0,0 +1,162 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
tmdbId: number;
title: string;
type: string;
backdropPath?: string | null;
overview?: string | null;
voteAverage?: number | null;
releaseDate?: string | null;
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const { navigate } = useRouter();
const primary = useCSSVariable("--color-primary") as string;
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.98]) }],
}));
const resolveMutation = useMutation(
orpc.titles.resolve.mutationOptions({
onSuccess: ({ id }) => {
navigate(`/title/${id}`);
},
onError: () => toast.error("Failed to load title"),
}),
);
const handlePress = useCallback(() => {
resolveMutation.mutate({
tmdbId: item.tmdbId,
type: item.type as "movie" | "tv",
});
}, [item.tmdbId, item.type, resolveMutation]);
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handlePress)();
});
const useGlass = isLiquidGlassAvailable();
return (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
opacity: resolveMutation.isPending ? 0.7 : 1,
borderCurve: "continuous",
},
]}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text className="mt-1 text-white/70 text-xs" numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</>
)}
</Animated.View>
</GestureDetector>
);
}
@@ -0,0 +1,85 @@
import { IconWifiOff } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import * as Network from "expo-network";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
const insets = useSafeAreaInsets();
const wasOnline = useRef(true);
useEffect(() => {
let mounted = true;
const handleState = (state: Network.NetworkState) => {
if (!mounted) return;
const offline = !state.isConnected || !state.isInternetReachable;
setIsOffline(offline);
if (offline && wasOnline.current) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
wasOnline.current = !offline;
};
Network.getNetworkStateAsync().then(handleState);
const subscription = Network.addNetworkStateListener(handleState);
return () => {
mounted = false;
subscription.remove();
};
}, []);
if (!isOffline) return null;
const useGlass = isLiquidGlassAvailable();
return (
<Animated.View
entering={SlideInUp.duration(300).springify().damping(18)}
exiting={SlideOutUp.duration(250)}
style={{
position: "absolute",
top: insets.top,
left: 0,
right: 0,
zIndex: 100,
}}
>
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
marginHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 10,
}}
>
<IconWifiOff size={16} color="white" />
<Text className="font-sans-medium text-[13px] text-white">
No internet connection
</Text>
</GlassView>
) : (
<View className="mx-4 flex-row items-center justify-center gap-2 rounded-xl bg-destructive px-4 py-2.5">
<IconWifiOff size={16} color="white" />
<Text className="font-sans-medium text-[13px] text-white">
No internet connection
</Text>
</View>
)}
</Animated.View>
);
}
+42
View File
@@ -0,0 +1,42 @@
import {
AndroidHaptics,
ImpactFeedbackStyle,
NotificationFeedbackType,
performAndroidHapticsAsync,
} from "expo-haptics";
export { ImpactFeedbackStyle, NotificationFeedbackType };
// See https://docs.expo.dev/versions/latest/sdk/haptics/#androidhaptics
const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
[ImpactFeedbackStyle.Light]: AndroidHaptics.Clock_Tick,
[ImpactFeedbackStyle.Medium]: AndroidHaptics.Context_Click,
[ImpactFeedbackStyle.Heavy]: AndroidHaptics.Long_Press,
[ImpactFeedbackStyle.Soft]: AndroidHaptics.Keyboard_Tap,
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
};
const notificationTypeToAndroid: Record<
NotificationFeedbackType,
AndroidHaptics
> = {
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
};
export async function impactAsync(
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
) {
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
}
export async function notificationAsync(
type: NotificationFeedbackType = NotificationFeedbackType.Success,
) {
return performAndroidHapticsAsync(notificationTypeToAndroid[type]);
}
export async function selectionAsync() {
return performAndroidHapticsAsync(AndroidHaptics.Clock_Tick);
}
+8 -43
View File
@@ -1,50 +1,15 @@
import {
impactAsync as _impactAsync,
notificationAsync as _notificationAsync,
AndroidHaptics,
ImpactFeedbackStyle,
impactAsync,
NotificationFeedbackType,
performAndroidHapticsAsync,
notificationAsync,
selectionAsync,
} from "expo-haptics";
export { ImpactFeedbackStyle, NotificationFeedbackType };
// See https://docs.expo.dev/versions/latest/sdk/haptics/#androidhaptics
const impactStyleToAndroid: Record<ImpactFeedbackStyle, AndroidHaptics> = {
[ImpactFeedbackStyle.Light]: AndroidHaptics.Clock_Tick,
[ImpactFeedbackStyle.Medium]: AndroidHaptics.Context_Click,
[ImpactFeedbackStyle.Heavy]: AndroidHaptics.Long_Press,
[ImpactFeedbackStyle.Soft]: AndroidHaptics.Keyboard_Tap,
[ImpactFeedbackStyle.Rigid]: AndroidHaptics.Virtual_Key,
};
const notificationTypeToAndroid: Record<
export {
ImpactFeedbackStyle,
impactAsync,
NotificationFeedbackType,
AndroidHaptics
> = {
[NotificationFeedbackType.Success]: AndroidHaptics.Confirm,
[NotificationFeedbackType.Warning]: AndroidHaptics.Segment_Tick,
[NotificationFeedbackType.Error]: AndroidHaptics.Reject,
notificationAsync,
selectionAsync,
};
export async function impactAsync(
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium,
) {
if (process.env.EXPO_OS === "ios") {
return _impactAsync(style);
}
if (process.env.EXPO_OS === "android") {
return performAndroidHapticsAsync(impactStyleToAndroid[style]);
}
}
export async function notificationAsync(
type: NotificationFeedbackType = NotificationFeedbackType.Success,
) {
if (process.env.EXPO_OS === "ios") {
return _notificationAsync(type);
}
if (process.env.EXPO_OS === "android") {
return performAndroidHapticsAsync(notificationTypeToAndroid[type]);
}
}