diff --git a/apps/app/app.json b/apps/app/app.json index 419e564..4530609 100644 --- a/apps/app/app.json +++ b/apps/app/app.json @@ -41,7 +41,15 @@ } } ], - "expo-secure-store" + "expo-secure-store", + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Allow UNSW Connect to use your location.", + "locationWhenInUsePermission": "Allow UNSW Connect to use your location." + } + ], + "@maplibre/maplibre-react-native" ], "experiments": { "typedRoutes": true, diff --git a/apps/app/app/(app)/map.tsx b/apps/app/app/(app)/map.tsx index 8639158..1b35cd8 100644 --- a/apps/app/app/(app)/map.tsx +++ b/apps/app/app/(app)/map.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { ActivityIndicator, + Linking, Modal, Pressable, ScrollView, @@ -11,14 +12,18 @@ import { } from "react-native"; import { BillboardPanel } from "@/components/billboard/BillboardPanel"; -import { Map } from "@/components/map/Map"; +import { CanvasModal } from "@/components/CanvasModal"; +import Map from "@/components/map/Map"; import { MapHUD } from "@/components/map/MapHUD"; import { UNSW_CAMPUS_ID, UNSW_CENTER } from "@/constants/coordinates"; +import { useUserLocation } from "@/hooks/useUserLocation"; import { ApiError } from "@/lib/api/client"; import { useBillboards, useCreateBillboard, usePois } from "@/lib/api/hooks"; import { colors } from "@/lib/theme"; export default function MapScreen() { + const mapRef = useRef<{ invalidateSize: () => void }>(null); + const [isCanvasOpen, setIsCanvasOpen] = useState(false); const [activeBillboardId, setActiveBillboardId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [body, setBody] = useState(""); @@ -26,6 +31,7 @@ export default function MapScreen() { const billboards = useBillboards({ campusId: UNSW_CAMPUS_ID }); const pois = usePois({ campusId: UNSW_CAMPUS_ID }); const createBillboard = useCreateBillboard(); + const { location, isDenied, canAskAgain, requestPermission } = useUserLocation(); const closeCreate = () => { setCreateOpen(false); @@ -59,15 +65,26 @@ export default function MapScreen() { setCreateError(err.message); return; } - setCreateError("Could not pin this whiteboard."); + console.log(err.message); + setCreateError("Could not pin this billboard."); }, }, ); }; + const handleEnableLocation = useCallback(async () => { + if (canAskAgain) { + await requestPermission(); + } else { + Linking.openURL("app-settings:"); + } + }, [canAskAgain, requestPermission]); + return ( ({ id: billboard.id, title: billboard.body, @@ -89,7 +106,11 @@ export default function MapScreen() { ) : null} - setCreateOpen(true)} /> + setCreateOpen(true)} + isPermissionDenied={isDenied} + onEnableLocation={handleEnableLocation} + /> - New whiteboard + New billboard + setIsCanvasOpen(false)} /> ); } diff --git a/apps/app/app/(auth)/sign-in.native.tsx b/apps/app/app/(auth)/sign-in.native.tsx index 50e9a16..a32fbba 100644 --- a/apps/app/app/(auth)/sign-in.native.tsx +++ b/apps/app/app/(auth)/sign-in.native.tsx @@ -8,7 +8,6 @@ export default function SignInScreen() { const router = useRouter(); useEffect(() => { - alert(isSignedIn); if (isSignedIn) { router.replace("/(app)/map"); } diff --git a/apps/app/app/billboard/[id].tsx b/apps/app/app/billboard/[id].tsx index ca75e2b..67cd81b 100644 --- a/apps/app/app/billboard/[id].tsx +++ b/apps/app/app/billboard/[id].tsx @@ -23,7 +23,7 @@ export default function BillboardDetailScreen() { return ( - + ); diff --git a/apps/app/components/billboard/BillboardPanel.tsx b/apps/app/components/billboard/BillboardPanel.tsx index 82f5bd5..af9477e 100644 --- a/apps/app/components/billboard/BillboardPanel.tsx +++ b/apps/app/components/billboard/BillboardPanel.tsx @@ -161,7 +161,7 @@ export function BillboardPanel({ id, onClose }: BillboardPanelProps) { if (billboard.isError || !billboard.data) { return ( - Whiteboard not found + Billboard not found {(billboard.error as Error | undefined)?.message ?? "It may have expired."} diff --git a/apps/app/components/map/Map.native.tsx b/apps/app/components/map/Map.native.tsx index 18667c5..f783e04 100644 --- a/apps/app/components/map/Map.native.tsx +++ b/apps/app/components/map/Map.native.tsx @@ -7,7 +7,7 @@ import { Camera, Map as MapLibre, Marker } from "@maplibre/maplibre-react-native import { fonts } from "@/app/theme"; import { UNSW_CENTER } from "@/constants/coordinates"; -import type { MapPoi, MapProps } from "./Map"; +import type { MapPoi, MapProps } from "./Map.types"; const THUNDERFOREST_API_KEY = process.env.EXPO_PUBLIC_THUNDERFOREST_KEY ?? "YOUR_API_KEY_HERE"; @@ -45,21 +45,19 @@ interface POIMarkerProps { function POIMarker({ poi, isSelected, onPress }: POIMarkerProps) { return ( - - - - + + + - - {/* Embedded Cross Lines */} - - - - - {/* Base Platform Bar (x=0, y=28, w=32, h=4) */} - + + {/* Embedded Cross Lines */} + + - + + {/* Base Platform Bar (x=0, y=28, w=32, h=4) */} + + ); } @@ -76,15 +74,15 @@ function BillboardMarker({ title: string; }) { return ( - - + + {title.slice(0, 2)} - + ); } @@ -110,22 +108,20 @@ function UserAvatarMarker({ coordinate, imageUrl }: UserAvatarMarkerProps) { } export default forwardRef<{ invalidateSize: () => void }, MapProps>(function Map( - { billboards, onBillboardPress, pois }, + { location, billboards, onBillboardPress, pois }, _ref, ) { const [selectedPOI, setSelectedPOI] = useState(null); const userAvatarUrl = Asset.fromModule(require("@/assets/images/avatar.png")).uri; + const userCoord: [number, number] = location + ? [location.longitude, location.latitude] + : [UNSW_CENTER.lng, UNSW_CENTER.lat]; return ( setSelectedPOI(null)}> - + {pois.map((poi) => ( void }, MapProps>(function Map /> ))} - + {selectedPOI && ( diff --git a/apps/app/components/map/Map.tsx b/apps/app/components/map/Map.tsx index ce8a3cf..fd1c162 100644 --- a/apps/app/components/map/Map.tsx +++ b/apps/app/components/map/Map.tsx @@ -1,34 +1 @@ -import { forwardRef } from "react"; -import { Platform } from "react-native"; -import MapNative from "./Map.native"; -import MapWeb from "./Map.web"; - -export type MapHandle = { invalidateSize: () => void }; - -export type MapPoint = { - id: string; - title: string; - lat: number; - lng: number; -}; - -export type MapPoi = MapPoint & { - description?: string | null; - visited?: boolean; -}; - -export type MapProps = { - billboards: MapPoint[]; - onBillboardPress?: (id: string) => void; - pois: MapPoi[]; -}; - -export const Map = forwardRef(function Map(props, ref) { - if (Platform.OS !== "web") { - return ; - } - - return ; -}); - -export default Map; +export { default } from "./Map.web"; diff --git a/apps/app/components/map/Map.types.ts b/apps/app/components/map/Map.types.ts new file mode 100644 index 0000000..b00270f --- /dev/null +++ b/apps/app/components/map/Map.types.ts @@ -0,0 +1,25 @@ +export type MapHandle = { invalidateSize: () => void }; + +export type MapPoint = { + id: string; + title: string; + lat: number; + lng: number; +}; + +export type MapPoi = MapPoint & { + description?: string | null; + visited?: boolean; +}; + +export type MapLocation = { + latitude: number; + longitude: number; +}; + +export type MapProps = { + location?: MapLocation | null; + billboards: MapPoint[]; + onBillboardPress?: (id: string) => void; + pois: MapPoi[]; +}; diff --git a/apps/app/components/map/Map.web.tsx b/apps/app/components/map/Map.web.tsx index 39ec287..fef0437 100644 --- a/apps/app/components/map/Map.web.tsx +++ b/apps/app/components/map/Map.web.tsx @@ -6,7 +6,7 @@ import { Platform, StyleSheet, View } from "react-native"; import { UNSW_CENTER } from "@/constants/coordinates"; import { useUserProfile } from "@/lib/userProfile"; -import type { MapPoint, MapPoi } from "./Map"; +import type { MapProps } from "./Map.types"; import { createBillboardIcon, createPOIIcon, createUserAvatarIcon } from "./markers"; const DRAWN_AVATAR_BG = "#faf7ef"; @@ -18,14 +18,8 @@ const TILE_ATTR = type MapHandle = { invalidateSize: () => void }; -type MapProps = { - billboards: MapPoint[]; - onBillboardPress?: (id: string) => void; - pois: MapPoi[]; -}; - export const Map = forwardRef(function MapWeb( - { billboards, onBillboardPress, pois }, + { location, billboards, onBillboardPress, pois }, ref, ) { const containerRef = useRef(null); @@ -34,9 +28,7 @@ export const Map = forwardRef(function MapWeb( const { avatarUri } = useUserProfile(); useImperativeHandle(ref, () => ({ - invalidateSize: () => { - mapRef.current?.invalidateSize(); - }, + invalidateSize: () => mapRef.current?.invalidateSize(), })); useEffect(() => { @@ -104,6 +96,16 @@ export const Map = forwardRef(function MapWeb( ); }, [avatarUri]); + useEffect(() => { + if (!mapRef.current || !userMarkerRef.current || !location) return; + + const { latitude, longitude } = location; + mapRef.current.setView([latitude, longitude], mapRef.current.getZoom(), { + animate: true, + }); + userMarkerRef.current.setLatLng([latitude, longitude]); + }, [location]); + return ; }); diff --git a/apps/app/components/map/MapHUD.tsx b/apps/app/components/map/MapHUD.tsx index b47d5ce..4830846 100644 --- a/apps/app/components/map/MapHUD.tsx +++ b/apps/app/components/map/MapHUD.tsx @@ -72,18 +72,33 @@ function AddButton({ onPress }: { onPress: () => void }) { type MapHUDProps = { onCreateBillboard: () => void; + isPermissionDenied?: boolean; + onEnableLocation?: () => void; }; -export function MapHUD({ onCreateBillboard }: MapHUDProps) { +export function MapHUD({ onCreateBillboard, isPermissionDenied, onEnableLocation }: MapHUDProps) { return ( - - - - - router.push("/quests" as any)} /> - - router.push("/studio" as any)} /> + {isPermissionDenied && ( + + Enable location to see nearby quests & POIs + [styles.permissionButton, { opacity: pressed ? 0.7 : 1 }]} + > + Enable + + + )} + + + + + + router.push("/quests" as any)} /> + + router.push("/studio" as any)} /> + @@ -92,13 +107,42 @@ export function MapHUD({ onCreateBillboard }: MapHUDProps) { const styles = StyleSheet.create({ container: { - alignItems: "flex-start", + position: "absolute", + left: 15, + right: 15, bottom: 15, + }, + bottomRow: { flexDirection: "row", justifyContent: "space-between", - left: 15, - position: "absolute", - right: 15, + alignItems: "flex-end", + }, + permissionBanner: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#5b7559", + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 10, + marginBottom: 10, + }, + permissionText: { + color: "#ffedd6", + fontFamily: fonts.family, + fontSize: 16, + flex: 1, + }, + permissionButton: { + backgroundColor: "#ffedd6", + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 6, + marginLeft: 12, + }, + permissionButtonText: { + color: "#5b7559", + fontFamily: fonts.family, + fontSize: 18, }, leftSection: { flexDirection: "column", diff --git a/apps/app/constants/coordinates.ts b/apps/app/constants/coordinates.ts index 973fb58..afbbca9 100644 --- a/apps/app/constants/coordinates.ts +++ b/apps/app/constants/coordinates.ts @@ -4,7 +4,7 @@ export const UNSW_CENTER = { lat: -33.917, lng: 151.231 } as const; export const DEMO_BILLBOARD = { id: "00000000-0000-4000-8000-000000000b01", - title: "Campus Whiteboard", + title: "Campus Billboard", lat: -33.9173, lng: 151.2313, } as const; diff --git a/apps/app/hooks/useUserLocation.ts b/apps/app/hooks/useUserLocation.ts new file mode 100644 index 0000000..058cf71 --- /dev/null +++ b/apps/app/hooks/useUserLocation.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import * as Location from "expo-location"; + +export interface UserLocation { + latitude: number; + longitude: number; + heading: number | null; + accuracy: number | null; +} + +export function useUserLocation() { + const [location, setLocation] = useState(null); + const [permission, setPermission] = useState(null); + const [isTracking, setIsTracking] = useState(false); + const subscriptionRef = useRef(null); + + const stopWatching = () => { + subscriptionRef.current?.remove(); + subscriptionRef.current = null; + setIsTracking(false); + }; + + const startWatching = async () => { + stopWatching(); + + const lastPos = await Location.getLastKnownPositionAsync({ maxAge: 60000 }); + if (lastPos) { + setLocation({ + latitude: lastPos.coords.latitude, + longitude: lastPos.coords.longitude, + heading: lastPos.coords.heading, + accuracy: lastPos.coords.accuracy, + }); + } + + const sub = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: 5000, + distanceInterval: 5, + }, + (loc) => { + setLocation({ + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + heading: loc.coords.heading, + accuracy: loc.coords.accuracy, + }); + setIsTracking(true); + }, + ); + + subscriptionRef.current = sub; + }; + + const requestPermission = async () => { + const perm = await Location.requestForegroundPermissionsAsync(); + setPermission(perm); + if (perm.granted) { + await startWatching(); + } + return perm; + }; + + useEffect(() => { + let cancelled = false; + + (async () => { + const perm = await Location.requestForegroundPermissionsAsync(); + if (cancelled) return; + setPermission(perm); + + if (perm.granted) { + await startWatching(); + } + })(); + + return () => { + cancelled = true; + stopWatching(); + }; + }, []); + + const isDenied = permission !== null && !permission.granted && !permission.canAskAgain; + const canAskAgain = permission?.canAskAgain ?? true; + + return { + location, + isDenied, + canAskAgain, + permission, + isTracking, + requestPermission, + }; +} diff --git a/apps/app/lib/api/client.ts b/apps/app/lib/api/client.ts index 1e4831d..5f3f660 100644 --- a/apps/app/lib/api/client.ts +++ b/apps/app/lib/api/client.ts @@ -44,7 +44,9 @@ export async function apiFetch({ init.body = JSON.stringify(body); } - const res = await fetch(`${API_BASE_URL}${path}`, init); + const url = `${API_BASE_URL}${path}`; + console.log("[apiFetch]", method, url, body); + const res = await fetch(url, init); const json: unknown = await res.json().catch(() => ({})); if (!res.ok) { diff --git a/apps/app/package.json b/apps/app/package.json index c8d466e..36d2d00 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -33,6 +33,7 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-location": "~19.0.8", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", diff --git a/bun.lock b/bun.lock index b582913..94e170a 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,7 @@ "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", "expo-linking": "~8.0.11", + "expo-location": "~19.0.8", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -1366,6 +1367,8 @@ "expo-linking": ["expo-linking@8.0.12", "", { "dependencies": { "expo-constants": "~18.0.13", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ=="], + "expo-location": ["expo-location@19.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA=="], + "expo-manifests": ["expo-manifests@1.0.11", "", { "dependencies": { "@expo/config": "~12.0.13", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw=="], "expo-modules-autolinking": ["expo-modules-autolinking@3.0.25", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg=="],