mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user