diff --git a/mobile/.env.example b/mobile/.env.example
new file mode 100644
index 0000000..e1f393b
--- /dev/null
+++ b/mobile/.env.example
@@ -0,0 +1,3 @@
+# prod only
+EXPO_PUBLIC_GOOGLE_MAPS_IOS_API_KEY=
+EXPO_PUBLIC_GOOGLE_MAPS_ANDROID_API_KEY=
\ No newline at end of file
diff --git a/mobile/app.json b/mobile/app.json
index 0208b51..0ba528b 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -9,7 +9,10 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "config": {
+ "googleMapsApiKey": ""
+ }
},
"android": {
"adaptiveIcon": {
@@ -19,7 +22,13 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
- "predictiveBackGestureEnabled": false
+ "predictiveBackGestureEnabled": false,
+ "config": {
+ "googleMaps": {
+ "apiKey": ""
+ }
+ },
+ "permissions": ["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]
},
"web": {
"output": "static",
@@ -38,6 +47,12 @@
"backgroundColor": "#000000"
}
}
+ ],
+ [
+ "expo-location",
+ {
+ "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
+ }
]
],
"experiments": {
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
index 54e11d0..ba401e2 100644
--- a/mobile/app/(tabs)/_layout.tsx
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -1,10 +1,10 @@
-import { Tabs } from 'expo-router';
-import React from 'react';
+import { Tabs } from "expo-router";
+import React from "react";
-import { HapticTab } from '@/components/haptic-tab';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
+import { HapticTab } from "@/components/haptic-tab";
+import { IconSymbol } from "@/components/ui/icon-symbol";
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from "@/hooks/use-color-scheme";
export default function TabLayout() {
const colorScheme = useColorScheme();
@@ -12,22 +12,27 @@ export default function TabLayout() {
return (
+ }}
+ >
,
+ title: "Map",
+ tabBarIcon: ({ color }) => (
+
+ ),
}}
/>
,
+ title: "Explore",
+ tabBarIcon: ({ color }) => (
+
+ ),
}}
/>
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
index 786b736..9568592 100644
--- a/mobile/app/(tabs)/index.tsx
+++ b/mobile/app/(tabs)/index.tsx
@@ -1,98 +1,314 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import GooglePlacesAutocomplete from "@/components/google-places-autocomplete";
+import LocationDrawer from "@/components/location-drawer";
+import * as Location from "expo-location";
+import { useEffect, useRef, useState } from "react";
+import { Platform, StyleSheet, View } from "react-native";
+import MapView, { Marker, PROVIDER_GOOGLE } from "react-native-maps";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+interface SearchResult {
+ latitude: number;
+ longitude: number;
+ name: string;
+ address: string;
+}
export default function HomeScreen() {
+ const insets = useSafeAreaInsets();
+ const [location, setLocation] = useState(
+ null
+ );
+ const [errorMsg, setErrorMsg] = useState(null);
+ const [searchResult, setSearchResult] = useState(null);
+ const [drawerVisible, setDrawerVisible] = useState(false);
+ const mapRef = useRef(null);
+
+ useEffect(() => {
+ (async () => {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ if (status !== "granted") {
+ setErrorMsg("Permission to access location was denied");
+ return;
+ }
+
+ const location = await Location.getCurrentPositionAsync({});
+ setLocation(location);
+ })();
+ }, []);
+
+ // Recenter map when drawer closes
+ useEffect(() => {
+ if (!drawerVisible && searchResult && mapRef.current) {
+ // Get current region to preserve zoom level
+ mapRef.current.getCamera().then((camera) => {
+ // Just adjust the center point without changing zoom
+ mapRef.current?.animateCamera(
+ {
+ center: {
+ latitude: searchResult.latitude,
+ longitude: searchResult.longitude,
+ },
+ zoom: camera.zoom,
+ },
+ { duration: 250 }
+ );
+ });
+ }
+ }, [drawerVisible, searchResult]);
+
+ if (errorMsg) {
+ console.error(errorMsg);
+ }
+
+ const handlePlaceSelect = (data: any, details: any) => {
+ const searchLocationData = {
+ latitude: details.geometry.location.lat,
+ longitude: details.geometry.location.lng,
+ name:
+ details.name ||
+ data.structured_formatting?.main_text ||
+ data.description.split(",")[0],
+ address: details.formatted_address || data.description,
+ };
+ setSearchResult(searchLocationData);
+ setDrawerVisible(true);
+
+ // Animate map to the search result (offset for drawer visibility)
+ if (mapRef.current) {
+ // Shift latitude to center in the visible area (drawer takes 60%, so center in top 40%)
+ // To center in the top 40%, shift up by 30% of the total
+ const adjustedLatitude = searchLocationData.latitude - 0.05 * 0.3;
+
+ mapRef.current.animateToRegion(
+ {
+ latitude: adjustedLatitude,
+ longitude: searchLocationData.longitude,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+ },
+ 1000
+ );
+ }
+ };
+
+ const handlePoiClick = async (e: any) => {
+ const poi = e.nativeEvent;
+ if (!poi.placeId) return;
+
+ // Close any open drawer first
+ if (drawerVisible) {
+ setDrawerVisible(false);
+ }
+
+ // Clear previous search result
+ setSearchResult(null);
+
+ try {
+ const apiKey = process.env.EXPO_PUBLIC_GOOGLE_MAPS_IOS_API_KEY || "";
+
+ if (apiKey) {
+ // Get place details using the place ID
+ const detailsUrl = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${poi.placeId}&key=${apiKey}`;
+ const response = await fetch(detailsUrl);
+ const data = await response.json();
+
+ if (data.result) {
+ const locationData = {
+ latitude: data.result.geometry.location.lat,
+ longitude: data.result.geometry.location.lng,
+ name: data.result.name,
+ address:
+ data.result.formatted_address || data.result.vicinity || "",
+ };
+
+ setSearchResult(locationData);
+ setDrawerVisible(true);
+
+ // Animate map to the POI (offset for drawer visibility)
+ if (mapRef.current) {
+ // Shift latitude to center in the visible area (drawer takes 60%, so center in top 40%)
+ // To center in the top 40%, shift up by 30% of the total
+ const adjustedLatitude = locationData.latitude - 0.005 * 0.3;
+
+ mapRef.current.animateToRegion(
+ {
+ latitude: adjustedLatitude,
+ longitude: locationData.longitude,
+ latitudeDelta: 0.005,
+ longitudeDelta: 0.005,
+ },
+ 500
+ );
+ }
+ }
+ }
+ } catch (error) {
+ console.error("POI click error:", error);
+ }
+ };
+
+ const handleMapPress = async (e: any) => {
+ const coordinate = e.nativeEvent.coordinate;
+ if (!coordinate) return;
+
+ try {
+ const apiKey = process.env.EXPO_PUBLIC_GOOGLE_MAPS_IOS_API_KEY || "";
+
+ // First, try to find a nearby place using Google Places Nearby Search
+ let locationData = null;
+
+ if (apiKey) {
+ try {
+ const nearbyUrl = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${coordinate.latitude},${coordinate.longitude}&radius=50&key=${apiKey}`;
+ const nearbyResponse = await fetch(nearbyUrl);
+ const nearbyData = await nearbyResponse.json();
+
+ if (nearbyData.results && nearbyData.results.length > 0) {
+ const place = nearbyData.results[0];
+
+ // Get full place details
+ const detailsUrl = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${place.place_id}&key=${apiKey}`;
+ const detailsResponse = await fetch(detailsUrl);
+ const detailsData = await detailsResponse.json();
+
+ if (detailsData.result) {
+ locationData = {
+ latitude: place.geometry.location.lat,
+ longitude: place.geometry.location.lng,
+ name: place.name,
+ address: detailsData.result.formatted_address || place.vicinity,
+ };
+ }
+ }
+ } catch (error) {
+ console.error("Places API error:", error);
+ }
+ }
+
+ // Fallback to reverse geocoding if no place found
+ if (!locationData) {
+ const reverseGeocode = await Location.reverseGeocodeAsync({
+ latitude: coordinate.latitude,
+ longitude: coordinate.longitude,
+ });
+
+ if (reverseGeocode.length > 0) {
+ const geo = reverseGeocode[0];
+ const addressParts = [
+ geo.street,
+ geo.city,
+ geo.region,
+ geo.country,
+ ].filter(Boolean);
+
+ const addressName =
+ addressParts.join(", ") ||
+ `Location (${coordinate.latitude.toFixed(
+ 4
+ )}, ${coordinate.longitude.toFixed(4)})`;
+
+ locationData = {
+ latitude: coordinate.latitude,
+ longitude: coordinate.longitude,
+ name: geo.street || geo.city || addressName,
+ address:
+ addressParts.join(", ") ||
+ `${coordinate.latitude.toFixed(
+ 6
+ )}, ${coordinate.longitude.toFixed(6)}`,
+ };
+ }
+ }
+
+ if (locationData) {
+ setSearchResult(locationData);
+ setDrawerVisible(true);
+ }
+ } catch (error) {
+ console.error("Map press error:", error);
+ }
+ };
+
+ const initialRegion = searchResult
+ ? {
+ latitude: searchResult.latitude,
+ longitude: searchResult.longitude,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+ }
+ : {
+ latitude: location?.coords.latitude || 37.78825,
+ longitude: location?.coords.longitude || -122.4324,
+ latitudeDelta: 0.0922,
+ longitudeDelta: 0.0421,
+ };
+
return (
-
+
+ {location && (
+
+ )}
+ {searchResult && (
+
+ )}
+
+
+
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
-
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
+
+ setDrawerVisible(false)}
+ />
+
);
}
const styles = StyleSheet.create({
- titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
+ container: {
+ flex: 1,
},
- stepContainer: {
- gap: 8,
- marginBottom: 8,
+ map: {
+ flex: 1,
},
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
- position: 'absolute',
+ searchContainer: {
+ position: "absolute",
+ left: 10,
+ right: 10,
+ zIndex: 1,
},
});
diff --git a/mobile/components/google-places-autocomplete.tsx b/mobile/components/google-places-autocomplete.tsx
new file mode 100644
index 0000000..5836aa2
--- /dev/null
+++ b/mobile/components/google-places-autocomplete.tsx
@@ -0,0 +1,238 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+
+interface GooglePlacesAutocompleteProps {
+ placeholder?: string;
+ onSelect: (data: any, details: any) => void;
+ apiKey?: string;
+ styles?: any;
+}
+
+// For Expo, environment variables need to be prefixed with EXPO_PUBLIC_
+// They're available at runtime via process.env
+const getApiKey = () => {
+ // Check if running in Expo and has environment variable
+ if (typeof process !== "undefined" && process.env) {
+ return (
+ process.env.EXPO_PUBLIC_GOOGLE_PLACES_API_KEY ||
+ process.env.EXPO_PUBLIC_GOOGLE_MAPS_IOS_API_KEY ||
+ ""
+ );
+ }
+ return "";
+};
+
+export default function GooglePlacesAutocomplete({
+ placeholder = "Search for a location...",
+ onSelect,
+ apiKey,
+ styles: customStyles = {},
+}: GooglePlacesAutocompleteProps) {
+ const [searchText, setSearchText] = useState("");
+ const [predictions, setPredictions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+
+ const fetchPredictions = async (input: string) => {
+ console.log("fetchPredictions called with:", input);
+ if (input.length < 2) {
+ setPredictions([]);
+ setShowSuggestions(false);
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const key = apiKey || getApiKey();
+ console.log("API Key:", key ? "Found" : "NOT FOUND");
+ if (!key) {
+ console.warn(
+ "Google Places API key not configured. Add EXPO_PUBLIC_GOOGLE_PLACES_API_KEY to your .env.local or pass as prop."
+ );
+ setIsLoading(false);
+ return;
+ }
+
+ const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(
+ input
+ )}&key=${key}`;
+ console.log("Fetching from URL:", url);
+
+ const response = await fetch(url);
+ const data = await response.json();
+ console.log("API Response:", data);
+
+ if (data.predictions) {
+ console.log("Found predictions:", data.predictions.length);
+ setPredictions(data.predictions);
+ setShowSuggestions(true);
+ } else {
+ console.log("No predictions in response:", data);
+ }
+ } catch (error) {
+ console.error("Autocomplete error:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const debounceTimer = useRef | null>(null);
+
+ const handleTextChange = (text: string) => {
+ console.log("Text changed:", text);
+ setSearchText(text);
+
+ // Debounce the API call
+ if (debounceTimer.current) {
+ clearTimeout(debounceTimer.current);
+ }
+
+ debounceTimer.current = setTimeout(() => {
+ console.log("Debounced - fetching predictions for:", text);
+ if (text.length >= 2) {
+ fetchPredictions(text);
+ } else {
+ setPredictions([]);
+ setShowSuggestions(false);
+ }
+ }, 300);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (debounceTimer.current) {
+ clearTimeout(debounceTimer.current);
+ }
+ };
+ }, []);
+
+ const handleSelect = async (placeId: string, description: string) => {
+ try {
+ // Get place details to get coordinates
+ const key = apiKey || getApiKey();
+ const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&key=${key}`;
+ const response = await fetch(url);
+ const data = await response.json();
+
+ if (data.result) {
+ const location = data.result.geometry.location;
+ onSelect(
+ { description },
+ {
+ geometry: {
+ location: {
+ lat: location.lat,
+ lng: location.lng,
+ },
+ },
+ formatted_address: data.result.formatted_address || description,
+ }
+ );
+ }
+
+ setSearchText(description);
+ setShowSuggestions(false);
+ setPredictions([]);
+ } catch (error) {
+ console.error("Place details error:", error);
+ }
+ };
+
+ return (
+
+
+ {
+ if (predictions.length > 0) setShowSuggestions(true);
+ }}
+ />
+ {isLoading && (
+
+ )}
+
+ {showSuggestions && predictions.length > 0 && (
+
+ item.place_id}
+ renderItem={({ item }) => (
+ handleSelect(item.place_id, item.description)}
+ >
+ {item.description}
+
+ )}
+ keyboardShouldPersistTaps="handled"
+ />
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: "relative",
+ zIndex: 10,
+ },
+ inputContainer: {
+ position: "relative",
+ },
+ input: {
+ backgroundColor: "white",
+ paddingHorizontal: 15,
+ paddingVertical: 12,
+ borderRadius: 999,
+ fontSize: 16,
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 3.84,
+ elevation: 5,
+ },
+ loader: {
+ position: "absolute",
+ right: 15,
+ top: 12,
+ },
+ listContainer: {
+ backgroundColor: "white",
+ borderRadius: 8,
+ marginTop: 5,
+ maxHeight: 200,
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 3.84,
+ elevation: 5,
+ },
+ listItem: {
+ paddingHorizontal: 15,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: "#e0e0e0",
+ },
+ listItemText: {
+ fontSize: 14,
+ color: "#333",
+ },
+});
diff --git a/mobile/components/location-drawer.tsx b/mobile/components/location-drawer.tsx
new file mode 100644
index 0000000..2cfa9a1
--- /dev/null
+++ b/mobile/components/location-drawer.tsx
@@ -0,0 +1,380 @@
+import React from "react";
+import {
+ Animated,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+interface LocationInfo {
+ name: string;
+ address: string;
+ latitude: number;
+ longitude: number;
+}
+
+interface LocationDrawerProps {
+ location: LocationInfo | null;
+ visible: boolean;
+ onClose: () => void;
+}
+
+export default function LocationDrawer({
+ location,
+ visible,
+ onClose,
+}: LocationDrawerProps) {
+ const insets = useSafeAreaInsets();
+ const translateY = React.useRef(new Animated.Value(600)).current;
+
+ React.useEffect(() => {
+ if (visible) {
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 10,
+ }).start();
+ } else {
+ Animated.spring(translateY, {
+ toValue: 600,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 10,
+ }).start();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [visible]);
+
+ if (!location) return null;
+
+ return (
+
+
+ {/* Drag handle */}
+
+
+ {/* Header with actions */}
+
+
+
+ 🔖
+
+
+ 📤
+
+
+ ✕
+
+
+
+
+
+ {/* Title */}
+ {location.name}
+ {location.address}
+
+ {/* Rating and details */}
+
+
+ 4.5
+ ⭐⭐⭐⭐⭐
+ (127)
+
+
+
+ {/* Distance/Time */}
+
+ 📍 6 min
+ •
+ Restaurant
+ •
+ $$
+
+
+ {/* Status */}
+
+ Closed
+ Opens 7 AM
+
+
+ {/* Action Buttons */}
+
+
+ Directions
+ →
+
+
+ ▲
+ Start
+
+
+ ⭐
+ Ask
+
+
+ 🍽️
+ Order
+
+
+
+ {/* Photos Section */}
+
+ Photos
+
+
+ 📷
+
+
+ 📷
+
+
+ 📷
+
+
+
+
+ {/* Coordinates (for debugging) */}
+
+
+ Coordinates
+
+
+ Lat:
+ {location.latitude.toFixed(6)}
+
+
+ Lng:
+ {location.longitude.toFixed(6)}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: "60%",
+ backgroundColor: "transparent",
+ },
+ hidden: {
+ display: "none",
+ },
+ drawer: {
+ flex: 1,
+ backgroundColor: "white",
+ borderTopLeftRadius: 15,
+ borderTopRightRadius: 15,
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: -2,
+ },
+ shadowOpacity: 0.15,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ handle: {
+ width: 36,
+ height: 4,
+ backgroundColor: "#ccc",
+ borderRadius: 2,
+ alignSelf: "center",
+ marginVertical: 8,
+ },
+ header: {
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ },
+ headerActions: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ alignItems: "center",
+ gap: 8,
+ },
+ iconButton: {
+ width: 36,
+ height: 36,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ iconText: {
+ fontSize: 20,
+ },
+ content: {
+ flex: 1,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: "bold",
+ paddingHorizontal: 16,
+ paddingBottom: 4,
+ color: "#212121",
+ },
+ address: {
+ fontSize: 15,
+ paddingHorizontal: 16,
+ paddingBottom: 12,
+ color: "#757575",
+ },
+ detailsRow: {
+ paddingHorizontal: 16,
+ marginBottom: 8,
+ },
+ rating: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ },
+ ratingValue: {
+ fontSize: 18,
+ fontWeight: "500",
+ color: "#212121",
+ },
+ stars: {
+ fontSize: 16,
+ },
+ ratingCount: {
+ fontSize: 14,
+ color: "#757575",
+ },
+ metaInfo: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ marginBottom: 8,
+ gap: 8,
+ },
+ metaText: {
+ fontSize: 14,
+ color: "#757575",
+ },
+ divider: {
+ fontSize: 14,
+ color: "#757575",
+ },
+ statusContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ marginBottom: 16,
+ gap: 8,
+ },
+ statusClosed: {
+ fontSize: 14,
+ color: "#d32f2f",
+ fontWeight: "500",
+ },
+ statusHours: {
+ fontSize: 14,
+ color: "#757575",
+ },
+ actionButtons: {
+ flexDirection: "row",
+ paddingHorizontal: 16,
+ gap: 8,
+ marginBottom: 20,
+ },
+ actionButtonPrimary: {
+ flex: 2.5,
+ backgroundColor: "#14a085",
+ borderRadius: 20,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ },
+ actionButtonSecondary: {
+ flex: 1,
+ backgroundColor: "#e0e0e0",
+ borderRadius: 20,
+ paddingVertical: 12,
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 4,
+ },
+ actionButtonText: {
+ fontSize: 13,
+ fontWeight: "500",
+ color: "#212121",
+ },
+ actionButtonTextPrimary: {
+ fontSize: 13,
+ fontWeight: "500",
+ color: "white",
+ },
+ actionButtonIcon: {
+ fontSize: 16,
+ },
+ actionButtonIconPrimary: {
+ fontSize: 16,
+ color: "white",
+ },
+ photosSection: {
+ paddingHorizontal: 16,
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#212121",
+ marginBottom: 12,
+ },
+ photoPlaceholder: {
+ width: 200,
+ height: 150,
+ backgroundColor: "#e0e0e0",
+ borderRadius: 8,
+ marginRight: 12,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ photoPlaceholderText: {
+ fontSize: 48,
+ },
+ coordsSection: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ backgroundColor: "#f5f5f5",
+ borderRadius: 8,
+ marginBottom: 20,
+ },
+ infoRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ paddingVertical: 6,
+ },
+ label: {
+ fontSize: 12,
+ color: "#757575",
+ fontWeight: "500",
+ },
+ value: {
+ fontSize: 12,
+ color: "#212121",
+ fontFamily: "monospace",
+ },
+});
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 66ce321..c59bda0 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -18,6 +18,7 @@
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
+ "expo-location": "~19.0.7",
"expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
@@ -28,6 +29,7 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -3293,6 +3295,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -6301,6 +6309,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-location": {
+ "version": "19.0.7",
+ "resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/expo-location/-/expo-location-19.0.7.tgz",
+ "integrity": "sha512-YNkh4r9E6ECbPkBCAMG5A5yHDgS0pw+Rzyd0l2ZQlCtjkhlODB55nMCKr5CZnUI0mXTkaSm8CwfoCO8n2MpYfg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-modules-autolinking": {
"version": "3.0.19",
"resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/expo-modules-autolinking/-/expo-modules-autolinking-3.0.19.tgz",
@@ -10565,6 +10582,28 @@
"react-native": "*"
}
},
+ "node_modules/react-native-maps": {
+ "version": "1.20.1",
+ "resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/react-native-maps/-/react-native-maps-1.20.1.tgz",
+ "integrity": "sha512-NZI3B5Z6kxAb8gzb2Wxzu/+P2SlFIg1waHGIpQmazDSCRkNoHNY4g96g+xS0QPSaG/9xRBbDNnd2f2/OW6t6LQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "^7946.0.13"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": ">= 17.0.1",
+ "react-native": ">= 0.64.3",
+ "react-native-web": ">= 0.11"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native-reanimated": {
"version": "4.1.3",
"resolved": "https://artifactory.rbx.com/artifactory/api/npm/npm-all/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 2e19a6b..1eda01f 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -21,6 +21,7 @@
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
+ "expo-location": "~19.0.7",
"expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
@@ -31,11 +32,12 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
+ "react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",