Skip to content

Instantly share code, notes, and snippets.

@darrencarlin
Last active November 16, 2025 20:14
Show Gist options
  • Select an option

  • Save darrencarlin/ab95cc63ab14beb0a2f65fc03f4f887c to your computer and use it in GitHub Desktop.

Select an option

Save darrencarlin/ab95cc63ab14beb0a2f65fc03f4f887c to your computer and use it in GitHub Desktop.
// app/profile/[username]/index.tsx
import { PublicProfileMapScreen } from "@/screens/public-profile-map";
export default function PublicProfileMap() {
return <PublicProfileMapScreen />;
}
// screens/public-profile-map/index.tsx
import { profileTappedLocationAtom } from "@/lib/state/atoms/app";
import { useProfile } from "@/lib/tanstack/hooks/use-profile";
import { Location, Review } from "@/lib/types";
import { convertReviewsToGeoJSON } from "@/lib/utils/maps";
import Mapbox from "@rnmapbox/maps";
import { useLocalSearchParams } from "expo-router";
import { useSetAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { StyleSheet, View } from "react-native";
import { ProfileMap } from "./components/map";
import { ProfileFloatingLocationCard } from "./components/map/profile-floating-card";
export const PublicProfileMapScreen = () => {
const { username } = useLocalSearchParams<{ username: string }>();
const setTappedLocation = useSetAtom(profileTappedLocationAtom);
const cameraRef = useRef<Mapbox.Camera | null>(null);
const mapViewRef = useRef<Mapbox.MapView | null>(null);
const styles = createStyles();
const { data: profile } = useProfile(username);
const geoJsonData = useMemo((): GeoJSON.FeatureCollection => {
if (!profile?.reviews) {
return {
type: "FeatureCollection",
features: [],
} as GeoJSON.FeatureCollection;
}
return convertReviewsToGeoJSON(profile.reviews);
}, [profile?.reviews]);
const sortedReviews = useMemo(() => {
if (!profile?.reviews) return [];
return [...profile.reviews].sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
}, [profile?.reviews]);
const lastReviewLocation = useMemo(() => {
if (!sortedReviews || sortedReviews.length === 0) return null;
const location = sortedReviews[0]?.location;
if (!location) return null;
return location as Location;
}, [sortedReviews]);
useEffect(() => {
// Clear the tapped location when the profile changes
return () => {
setTappedLocation(null);
};
}, []);
useEffect(() => {}, []);
return (
<View style={styles.container}>
<ProfileMap
cameraRef={cameraRef}
mapViewRef={mapViewRef}
data={geoJsonData}
lastReviewLocation={lastReviewLocation}
profileName={profile?.name}
/>
<ProfileFloatingLocationCard
cameraRef={cameraRef}
mapViewRef={mapViewRef}
data={geoJsonData}
reviews={sortedReviews as Review[]}
/>
</View>
);
};
const createStyles = () =>
StyleSheet.create({
container: {
flex: 1,
overflow: "hidden", // Contain absolutely positioned children on Android
},
});
// screens/public-profile-map/components/map/index.tsx
import { STYLE_URL } from "@/lib/constants/constants";
import { Location } from "@/lib/types";
import Mapbox, { Camera, LocationPuck, MapView } from "@rnmapbox/maps";
import Constants from "expo-constants";
import { useEffect, useMemo, useState } from "react";
import { StyleSheet, View } from "react-native";
import { PofileMapButtons } from "@/components/buttons/profile-map-buttons";
import { ProfileMapMarkers } from "./profile-map-markers";
Mapbox.setAccessToken(Constants.expoConfig?.extra?.mapboxAccessToken);
interface Props {
cameraRef: React.RefObject<Mapbox.Camera | null>;
mapViewRef?: React.RefObject<Mapbox.MapView | null>;
data: GeoJSON.FeatureCollection;
lastReviewLocation?: Location | null;
profileName?: string;
}
export const ProfileMap = ({
cameraRef: externalCameraRef,
mapViewRef: externalMapViewRef,
data,
lastReviewLocation,
profileName,
}: Props) => {
// Use external refs if provided
const cameraRef = externalCameraRef;
const mapViewRef = externalMapViewRef;
const defaultViewState = useMemo(() => {
if (lastReviewLocation?.longitude && lastReviewLocation?.latitude) {
return {
longitude: lastReviewLocation.longitude,
latitude: lastReviewLocation.latitude,
zoom: 18,
};
}
return {
longitude: -122.4194,
latitude: 37.7749,
zoom: 10,
};
}, [lastReviewLocation]);
const [viewState, setViewState] = useState(defaultViewState);
// Update viewState and camera when lastReviewLocation changes
useEffect(() => {
setViewState(defaultViewState);
if (
cameraRef?.current &&
defaultViewState.longitude &&
defaultViewState.latitude
) {
cameraRef.current.setCamera({
centerCoordinate: [
defaultViewState.longitude,
defaultViewState.latitude,
],
zoomLevel: defaultViewState.zoom,
animationDuration: 500,
});
}
}, [defaultViewState, cameraRef]);
const updateViewState = async () => {
if (!mapViewRef?.current) return;
const center = await mapViewRef.current.getCenter();
const zoom = await mapViewRef.current.getZoom();
if (center && zoom !== undefined) {
setViewState({
longitude: center[0],
latitude: center[1],
zoom,
});
}
};
const handleTouchEnd = async () => {
await updateViewState();
};
const styles = createStyles();
return (
<>
<PofileMapButtons profileName={profileName} />
<View style={styles.container}>
<MapView
ref={mapViewRef}
style={styles.map}
styleURL={STYLE_URL}
attributionEnabled={false}
logoEnabled={false}
scaleBarEnabled={false}
compassEnabled={false}
onTouchEnd={handleTouchEnd}
rotateEnabled={false}
pitchEnabled={false}
>
<Camera
ref={cameraRef}
defaultSettings={{
centerCoordinate: [viewState.longitude, viewState.latitude],
zoomLevel: viewState.zoom,
}}
/>
<LocationPuck
puckBearingEnabled
puckBearing="heading"
pulsing={{ isEnabled: true }}
/>
<ProfileMapMarkers
cameraRef={cameraRef}
mapViewRef={mapViewRef}
data={data}
/>
</MapView>
</View>
</>
);
};
const createStyles = () =>
StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
});
// screens/public-profile-map/components/map/profile-floating-card.tsx
import { useFloatingCard } from "@/lib/hooks/use-floating-card";
import { useTheme } from "@/lib/hooks/use-theme";
import {
locationIdAtom,
profileTappedLocationAtom,
} from "@/lib/state/atoms/app";
import { Location, Review } from "@/lib/types";
import Mapbox from "@rnmapbox/maps";
import { useRouter } from "expo-router";
import { useSetAtom } from "jotai";
import { ChevronLeft, ChevronRight } from "lucide-react-native";
import { useMemo } from "react";
import { StyleSheet } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { ProfileLocationCard } from "./profile-location-card";
const CARD_BOTTOM_OFFSET = 40;
const CARD_WIDTH_PERCENTAGE = 0.9;
interface Props {
cameraRef?: React.RefObject<Mapbox.Camera | null>;
mapViewRef?: React.RefObject<Mapbox.MapView | null>;
data: GeoJSON.FeatureCollection;
reviews?: Review[];
}
export const ProfileFloatingLocationCard = ({
cameraRef,
mapViewRef,
data,
reviews = [],
}: Props) => {
const theme = useTheme();
const router = useRouter();
const setLocationId = useSetAtom(locationIdAtom);
// Get all locations from GeoJSON
const allLocations = useMemo(() => {
if (!data?.features) return [];
return data.features
.filter((f) => f.properties && f.properties.id)
.map((f) => f.properties as Location);
}, [data]);
// Use shared floating card hook with profile-specific atom
const {
tappedLocation,
setTappedLocation,
leftMarker,
rightMarker,
panGesture,
cardAnimatedStyle,
leftArrowAnimatedStyle,
rightArrowAnimatedStyle,
} = useFloatingCard({
allLocations,
cameraRef,
mapViewRef,
tappedLocationAtom: profileTappedLocationAtom,
});
// Find the review that matches the tapped location
const currentReview = useMemo(() => {
if (!tappedLocation || !reviews) return null;
return (
reviews.find((review) => review.location?.id === tappedLocation.id) ??
null
);
}, [tappedLocation, reviews]);
const handleLocationCardPress = () => {
if (tappedLocation) {
setTappedLocation(null);
setLocationId(tappedLocation.id);
router.push("/location");
}
};
// Don't render if no tapped location
if (!tappedLocation) {
return null;
}
const styles = createStyles(theme);
return (
<GestureDetector gesture={panGesture}>
<Animated.View
key={tappedLocation.id}
style={[styles.wrapper, cardAnimatedStyle]}
collapsable={false}
removeClippedSubviews={false}
renderToHardwareTextureAndroid={true}
needsOffscreenAlphaCompositing={true}
>
{/* Left Arrow Indicator */}
{leftMarker && (
<Animated.View
style={[
styles.arrowContainer,
styles.leftArrow,
leftArrowAnimatedStyle,
]}
>
<ChevronLeft size={24} color="#FFFFFF" />
</Animated.View>
)}
{/* Right Arrow Indicator */}
{rightMarker && (
<Animated.View
style={[
styles.arrowContainer,
styles.rightArrow,
rightArrowAnimatedStyle,
]}
>
<ChevronRight size={24} color="#FFFFFF" />
</Animated.View>
)}
<ProfileLocationCard
location={tappedLocation}
review={currentReview}
showShadow
fadeIn={false}
onPress={handleLocationCardPress}
/>
</Animated.View>
</GestureDetector>
);
};
const createStyles = (theme: ReturnType<typeof useTheme>) =>
StyleSheet.create({
wrapper: {
width: `${CARD_WIDTH_PERCENTAGE * 100}%`,
position: "absolute",
bottom: CARD_BOTTOM_OFFSET,
alignSelf: "center",
zIndex: 1000, // Ensure it stays above map but below navigation
elevation: 1000, // For Android layering
},
arrowContainer: {
position: "absolute",
top: "50%",
marginTop: -20, // Half of container height (40px / 2)
backgroundColor: theme.colors.guinnessGray,
borderRadius: 20,
padding: 8,
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
zIndex: 10,
},
leftArrow: {
borderWidth: 1,
borderColor: theme.colors.guinnessBorder,
left: -40,
},
rightArrow: {
borderWidth: 1,
borderColor: theme.colors.guinnessBorder,
right: -40,
},
});
// lib/hooks/use-floating-card.ts
import {
ANIMATION_DURATION,
MARKER_ZOOM_LEVEL,
} from "@/lib/constants/constants";
import { tappedLocationAtom } from "@/lib/state/atoms/app";
import { Location } from "@/lib/types";
import { findNearestMarkers } from "@/lib/utils/location-utils";
import Mapbox from "@rnmapbox/maps";
import { PrimitiveAtom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { Gesture } from "react-native-gesture-handler";
import {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
const SWIPE_THRESHOLD = 50; // Minimum swipe distance to trigger navigation
const DOWN_SWIPE_THRESHOLD = 50; // Minimum swipe distance to close card
const SLIDE_UP_DISTANCE = 100; // Distance to slide up from
const SLIDE_UP_DURATION = 300; // Animation duration in ms
const FADE_DURATION = 400; // Fade animation duration in ms
interface UseFloatingCardProps {
allLocations: Location[];
cameraRef?: React.RefObject<Mapbox.Camera | null>;
mapViewRef?: React.RefObject<Mapbox.MapView | null>;
tappedLocationAtom?: PrimitiveAtom<Location | null>;
}
export const useFloatingCard = ({
allLocations,
cameraRef,
mapViewRef,
tappedLocationAtom: locationAtom = tappedLocationAtom,
}: UseFloatingCardProps) => {
const [tappedLocation, setTappedLocation] = useAtom(locationAtom);
// Track if this is the first appearance
const isFirstAppearance = useRef(true);
// Animation values
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const slideUpOffset = useSharedValue(SLIDE_UP_DISTANCE);
const opacity = useSharedValue(1);
const leftArrowOpacity = useSharedValue(0);
const rightArrowOpacity = useSharedValue(0);
// Find nearest markers
const { left: leftMarker, right: rightMarker } = useMemo(() => {
if (!tappedLocation || allLocations.length === 0) {
return { left: null, right: null };
}
return findNearestMarkers(tappedLocation, allLocations);
}, [tappedLocation, allLocations]);
// Trigger animation when card appears
useEffect(() => {
if (tappedLocation) {
if (isFirstAppearance.current) {
// First time: slide up
// Use scheduleOnUI to ensure animation runs on UI thread (critical for real Android devices)
scheduleOnUI(() => {
"worklet";
opacity.value = 1;
slideUpOffset.value = SLIDE_UP_DISTANCE;
slideUpOffset.value = withTiming(0, {
duration: SLIDE_UP_DURATION,
});
});
isFirstAppearance.current = false;
} else {
// Subsequent times: fade in
scheduleOnUI(() => {
"worklet";
slideUpOffset.value = 0;
opacity.value = 0;
opacity.value = withTiming(1, {
duration: FADE_DURATION,
});
});
}
} else {
// Reset when card is closed
scheduleOnUI(() => {
"worklet";
slideUpOffset.value = SLIDE_UP_DISTANCE;
opacity.value = 1;
});
isFirstAppearance.current = true;
}
}, [tappedLocation]);
// Function to move camera to a location
const moveCameraToLocation = async (location: Location) => {
if (!cameraRef?.current) return;
const currentZoom =
(await mapViewRef?.current?.getZoom()) ?? MARKER_ZOOM_LEVEL;
const targetZoom =
currentZoom < MARKER_ZOOM_LEVEL ? MARKER_ZOOM_LEVEL : currentZoom;
cameraRef.current.setCamera({
centerCoordinate: [location.longitude, location.latitude],
zoomLevel: targetZoom,
animationDuration: ANIMATION_DURATION,
});
};
// Wrapper functions to call from gesture handler
const navigateToLeftMarker = async () => {
if (leftMarker) {
setTappedLocation(leftMarker);
await moveCameraToLocation(leftMarker);
}
};
const navigateToRightMarker = async () => {
if (rightMarker) {
setTappedLocation(rightMarker);
await moveCameraToLocation(rightMarker);
}
};
const closeCard = () => {
setTappedLocation(null);
};
// Capture marker availability for use in worklets
const hasLeftMarker = leftMarker !== null;
const hasRightMarker = rightMarker !== null;
// Pan gesture handler
const panGesture = Gesture.Pan()
.onUpdate((event) => {
const absX = Math.abs(event.translationX);
const absY = Math.abs(event.translationY);
// Determine if swipe is primarily horizontal or vertical
const isHorizontalSwipe = absX > absY;
const isVerticalSwipe = absY > absX;
if (isHorizontalSwipe) {
// Handle horizontal swipe for navigation
translateX.value = event.translationX;
translateY.value = 0;
// Show arrows based on swipe direction
if (event.translationX > 0 && hasLeftMarker) {
// Swiping right (showing left arrow)
leftArrowOpacity.value = Math.min(
Math.abs(event.translationX) / SWIPE_THRESHOLD,
1,
);
rightArrowOpacity.value = 0;
} else if (event.translationX < 0 && hasRightMarker) {
// Swiping left (showing right arrow)
rightArrowOpacity.value = Math.min(
Math.abs(event.translationX) / SWIPE_THRESHOLD,
1,
);
leftArrowOpacity.value = 0;
} else {
leftArrowOpacity.value = 0;
rightArrowOpacity.value = 0;
}
} else if (isVerticalSwipe && event.translationY > 0) {
// Handle downward swipe for closing
translateY.value = event.translationY;
translateX.value = 0;
leftArrowOpacity.value = 0;
rightArrowOpacity.value = 0;
}
})
.onEnd((event) => {
const absTranslationX = Math.abs(event.translationX);
const absTranslationY = Math.abs(event.translationY);
const isHorizontalSwipe = absTranslationX > absTranslationY;
if (isHorizontalSwipe) {
// Reset horizontal position
translateX.value = withSpring(0);
translateY.value = withSpring(0);
// Check if swipe threshold is met
if (absTranslationX > SWIPE_THRESHOLD) {
if (event.translationX > 0 && hasLeftMarker) {
// Swiped right - go to left marker
scheduleOnRN(navigateToLeftMarker);
} else if (event.translationX < 0 && hasRightMarker) {
// Swiped left - go to right marker
scheduleOnRN(navigateToRightMarker);
}
}
// Hide arrows
leftArrowOpacity.value = withSpring(0);
rightArrowOpacity.value = withSpring(0);
} else if (event.translationY > DOWN_SWIPE_THRESHOLD) {
// Swiped down - close card
translateX.value = withSpring(0);
translateY.value = withSpring(0);
leftArrowOpacity.value = withSpring(0);
rightArrowOpacity.value = withSpring(0);
scheduleOnRN(closeCard);
} else {
// Reset both positions if threshold not met
translateX.value = withSpring(0);
translateY.value = withSpring(0);
leftArrowOpacity.value = withSpring(0);
rightArrowOpacity.value = withSpring(0);
}
});
// Animated styles
const cardAnimatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateX: translateX.value },
{ translateY: slideUpOffset.value + translateY.value },
],
}));
const leftArrowAnimatedStyle = useAnimatedStyle(() => ({
opacity: leftArrowOpacity.value,
}));
const rightArrowAnimatedStyle = useAnimatedStyle(() => ({
opacity: rightArrowOpacity.value,
}));
return {
tappedLocation,
setTappedLocation,
leftMarker,
rightMarker,
panGesture,
cardAnimatedStyle,
leftArrowAnimatedStyle,
rightArrowAnimatedStyle,
};
};
// components/buttons/profile-map-buttons.tsx
import { View } from "react-native";
import { BackButton } from "./back-button";
export const PofileMapButtons = ({
profileName,
}: {
profileName?: string | null;
}) => {
return (
<View
style={{
position: "absolute",
top: 10,
left: 10,
right: 10,
zIndex: 10,
}}
pointerEvents="box-none"
>
<BackButton text={`to ${profileName}'s Profile`} />
</View>
);
};
// package.json
{
"name": "theguinnessmap",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"format": "prettier . --write",
"check": "npx expo install --check",
"prepare": "husky",
"clean": "npx expo prebuild --clean",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/expo": "^1.3.34",
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.3",
"@hookform/resolvers": "^5.2.2",
"@react-native-community/slider": "5.0.1",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@react-navigation/elements": "^2.8.1",
"@react-navigation/native": "^7.1.8",
"@rnmapbox/maps": "^10.2.7",
"@shopify/flash-list": "2.0.2",
"@tanstack/react-query": "^5.90.8",
"@the-guinness-map/gmap-be": "^1.9.5",
"better-auth": "^1.3.34",
"dotenv": "^17.2.3",
"expo": "54.0.23",
"expo-apple-authentication": "~8.0.7",
"expo-application": "~7.0.7",
"expo-asset": "~12.0.9",
"expo-constants": "~18.0.10",
"expo-crypto": "~15.0.7",
"expo-device": "~8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.8",
"expo-linear-gradient": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-location": "~19.0.7",
"expo-navigation-bar": "^5.0.9",
"expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-updates": "~29.0.12",
"expo-video": "~3.0.14",
"expo-web-browser": "~15.0.9",
"hono": "^4.10.5",
"jotai": "^2.15.1",
"lucide-react-native": "^0.553.0",
"posthog-react-native": "^4.11.0",
"posthog-react-native-session-replay": "^1.2.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.66.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "~4.1.5",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.2",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/react": "~19.1.10",
"eslint": "^9.39.1",
"eslint-config-expo": "~10.0.0",
"husky": "^9.1.7",
"prettier": "3.6.2",
"typescript": "~5.9.3"
},
"overrides": {
"react": "19.1.0",
"react-dom": "19.1.0"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"@react-native-cookies/cookies"
]
}
}
},
"private": true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment