Last active
November 16, 2025 20:14
-
-
Save darrencarlin/ab95cc63ab14beb0a2f65fc03f4f887c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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