north-star / commit
getting firebase stuff working
mobile/app.json | 11 +- mobile/package-lock.json | 58 +++++++++ mobile/package.json | 5 +- mobile/src/core/bearing.ts | 33 +++++ mobile/src/features/friends/sync.ts | 69 +++++++++++ mobile/src/platform/firebase.ts | 17 ++- mobile/src/platform/geolocation.ts | 50 ++++++++ mobile/src/platform/heading.ts | 33 +++++ mobile/src/platform/profile.ts | 19 +++ mobile/src/screens/room_screen.tsx | 235 ++++++++++++++++++++++++++++++++++-- 10 files changed, 513 insertions(+), 17 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index b89c91f..8545409 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -12,9 +12,16 @@ "backgroundColor": "#ffffff" }, "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSLocationWhenInUseUsageDescription": "Friend Compass uses your location to share it with others in your room." + } }, "android": { + "permissions": [ + "ACCESS_FINE_LOCATION", + "ACCESS_COARSE_LOCATION" + ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/android-icon-foreground.png", @@ -28,4 +35,4 @@ "favicon": "./assets/favicon.png" } } -} +} \ No newline at end of file diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 29c9441..e652472 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -8,7 +8,10 @@ "name": "mobile", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.0", + "expo-location": "~19.0.8", + "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", @@ -2761,6 +2764,18 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -4294,6 +4309,15 @@ } } }, + "node_modules/expo-location": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", + "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -4323,6 +4347,19 @@ "react-native": "*" } }, + "node_modules/expo-sensors": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-15.0.8.tgz", + "integrity": "sha512-ttibOSCYjFAMIfjV+vVukO1v7GKlbcPRfxcRqbTaSMGneewDwVSXbGFImY530fj1BR3mWq4n9jHnuDp8tAEY9g==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -5192,6 +5229,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -5940,6 +5986,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 9e96ff2..9fc990f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -10,7 +10,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "expo": "~54.0.0", + "expo-location": "~19.0.8", + "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", @@ -21,4 +24,4 @@ "typescript": "~5.9.2" }, "private": true -} \ No newline at end of file +} diff --git a/mobile/src/core/bearing.ts b/mobile/src/core/bearing.ts new file mode 100644 index 0000000..6caae37 --- /dev/null +++ b/mobile/src/core/bearing.ts @@ -0,0 +1,33 @@ +export type LatLon = { lat: number; lon: number }; + +export const normalizeAngle = (angle: number) => ((angle % 360) + 360) % 360; + +export const getBearingDeg = (from: LatLon, to: LatLon): number => { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLon = toRad(to.lon - from.lon); + const lat1 = toRad(from.lat); + const lat2 = toRad(to.lat); + const y = Math.sin(dLon) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + return normalizeAngle((Math.atan2(y, x) * 180) / Math.PI); +}; + +export const getDistanceMeters = (from: LatLon, to: LatLon): number => { + const toRad = (d: number) => (d * Math.PI) / 180; + const R = 6371000; + const dLat = toRad(to.lat - from.lat); + const dLon = toRad(to.lon - from.lon); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(from.lat)) * Math.cos(toRad(to.lat)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +export const formatDistance = (meters: number): string => { + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +}; + +/** Arrow rotation relative to the top of the screen given device heading. */ +export const getRelativeBearingDeg = (bearingDeg: number, headingDeg: number): number => + normalizeAngle(bearingDeg - headingDeg); diff --git a/mobile/src/features/friends/sync.ts b/mobile/src/features/friends/sync.ts new file mode 100644 index 0000000..84ed18d --- /dev/null +++ b/mobile/src/features/friends/sync.ts @@ -0,0 +1,69 @@ +import { onValue, ref, serverTimestamp, set, type Database } from 'firebase/database'; + +export type MobileFriendRecord = { + id: string; + name: string; + color: string; + timestamp?: number; + lastSeen?: number; + position?: { lat: number; lon: number }; +}; + +export type LatLon = { lat: number; lon: number }; + +export const upsertSelfFriend = async ({ + db, + roomId, + uid, + name, + color, + position +}: { + db: Database; + roomId: string; + uid: string; + name: string; + color: string; + position: LatLon | null; +}) => { + const friendRef = ref(db, `rooms/${roomId}/friends/${uid}`); + await set(friendRef, { + id: uid, + name, + color, + position, + timestamp: Date.now(), + lastSeen: serverTimestamp() + }); +}; + +export const subscribeToFriends = ({ + db, + roomId, + onUpdate, + onError +}: { + db: Database; + roomId: string; + onUpdate: (friends: MobileFriendRecord[]) => void; + onError: (error: Error) => void; +}) => { + const friendsRef = ref(db, `rooms/${roomId}/friends`); + + return onValue( + friendsRef, + (snapshot) => { + const value = snapshot.val() as Record<string, MobileFriendRecord> | null; + const nextFriends = value ? Object.values(value) : []; + nextFriends.sort((a, b) => { + const aName = (a.name || '').toLowerCase(); + const bName = (b.name || '').toLowerCase(); + return aName.localeCompare(bName); + }); + onUpdate(nextFriends); + }, + (error) => { + onError(error as Error); + } + ); +}; diff --git a/mobile/src/platform/firebase.ts b/mobile/src/platform/firebase.ts index ce6a35c..27aa473 100644 --- a/mobile/src/platform/firebase.ts +++ b/mobile/src/platform/firebase.ts @@ -1,6 +1,6 @@ -import { initializeApp } from 'firebase/app'; +import { getApp, getApps, initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously } from 'firebase/auth'; -import { getDatabase } from 'firebase/database'; +import { getDatabase, type Database } from 'firebase/database'; const firebaseConfig = { apiKey: 'AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM', @@ -12,18 +12,23 @@ const firebaseConfig = { databaseURL: 'https://north-star-e5dee-default-rtdb.firebaseio.com/' }; -let cachedSession: { uid: string } | null = null; +export type FirebaseSession = { + uid: string; + db: Database; +}; + +let cachedSession: FirebaseSession | null = null; export const initFirebaseSession = async () => { if (cachedSession) return cachedSession; - const app = initializeApp(firebaseConfig); + const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig); const auth = getAuth(app); - getDatabase(app); + const db = getDatabase(app); const authSignInResponse = await signInAnonymously(auth); const uid = authSignInResponse.user.uid; - cachedSession = { uid }; + cachedSession = { uid, db }; return cachedSession; }; \ No newline at end of file diff --git a/mobile/src/platform/geolocation.ts b/mobile/src/platform/geolocation.ts new file mode 100644 index 0000000..25162c0 --- /dev/null +++ b/mobile/src/platform/geolocation.ts @@ -0,0 +1,50 @@ +import * as Location from 'expo-location'; + +export type LatLon = { lat: number; lon: number }; + +export type GeolocationResult = + | { status: 'ok'; position: LatLon } + | { status: 'denied' } + | { status: 'error'; message: string }; + +export const requestLocationPermission = async (): Promise<boolean> => { + const { status } = await Location.requestForegroundPermissionsAsync(); + return status === Location.PermissionStatus.GRANTED; +}; + +export const getHasLocationPermission = async (): Promise<boolean> => { + const { status } = await Location.getForegroundPermissionsAsync(); + return status === Location.PermissionStatus.GRANTED; +}; + +export const watchPosition = ( + onUpdate: (position: LatLon) => void, + onError: (message: string) => void +): (() => void) => { + let subscription: Location.LocationSubscription | null = null; + + Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + distanceInterval: 1, + timeInterval: 5000 + }, + (location) => { + onUpdate({ + lat: location.coords.latitude, + lon: location.coords.longitude + }); + } + ) + .then((sub) => { + subscription = sub; + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Unknown location error'; + onError(message); + }); + + return () => { + subscription?.remove(); + }; +}; diff --git a/mobile/src/platform/heading.ts b/mobile/src/platform/heading.ts new file mode 100644 index 0000000..65bee36 --- /dev/null +++ b/mobile/src/platform/heading.ts @@ -0,0 +1,33 @@ +import { Magnetometer } from 'expo-sensors'; + +/** + * Watches the device magnetometer and calls onHeading with the + * compass bearing in degrees (0 = north, 90 = east, etc.). + * Returns an unsubscribe function. + */ +export const watchHeading = ( + onHeading: (headingDeg: number) => void, + onError: (message: string) => void +): (() => void) => { + Magnetometer.setUpdateInterval(200); + + const subscription = Magnetometer.addListener(({ x, y }) => { + // atan2 gives angle from east; convert to bearing from north + let heading = Math.atan2(y, x) * (180 / Math.PI); + // Rotate: magnetic east is 90° from geographic north + heading = 90 - heading; + // Normalize to 0-360 + heading = ((heading % 360) + 360) % 360; + onHeading(heading); + }); + + Magnetometer.isAvailableAsync() + .then((available) => { + if (!available) onError('Magnetometer unavailable on this device'); + }) + .catch(() => { + onError('Could not check magnetometer availability'); + }); + + return () => subscription.remove(); +}; diff --git a/mobile/src/platform/profile.ts b/mobile/src/platform/profile.ts new file mode 100644 index 0000000..34984d9 --- /dev/null +++ b/mobile/src/platform/profile.ts @@ -0,0 +1,19 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const KEY_NAME = 'profile:name'; +const KEY_COLOR = 'profile:color'; + +export const loadProfile = async (): Promise<{ name: string; color: string } | null> => { + const [name, color] = await AsyncStorage.multiGet([KEY_NAME, KEY_COLOR]); + const savedName = name[1]; + const savedColor = color[1]; + if (!savedName || !savedColor) return null; + return { name: savedName, color: savedColor }; +}; + +export const saveProfile = async (name: string, color: string): Promise<void> => { + await AsyncStorage.multiSet([ + [KEY_NAME, name], + [KEY_COLOR, color], + ]); +}; diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx index 2a33fb1..695ae43 100644 --- a/mobile/src/screens/room_screen.tsx +++ b/mobile/src/screens/room_screen.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, @@ -8,10 +8,17 @@ import { TextInput, View } from 'react-native'; +import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing'; import { buildRoomHash, parseRoomFromHash } from '../core/room'; -import { initFirebaseSession } from '../platform/firebase'; +import { watchHeading } from '../platform/heading'; +import { loadProfile, saveProfile } from '../platform/profile'; +import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync'; +import { initFirebaseSession, type FirebaseSession } from '../platform/firebase'; +import { requestLocationPermission, watchPosition, type LatLon } from '../platform/geolocation'; import { PUBLIC_SETTINGS } from '../public_settings'; +const MOBILE_SYNC_INTERVAL_MS = 10000; + const randomRoomId = () => { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; let out = ''; @@ -21,22 +28,63 @@ const randomRoomId = () => { return out; }; +const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`; + +const normalizeColor = (value: string) => { + const candidate = value.trim().toLowerCase(); + if (!/^#[0-9a-f]{6}$/.test(candidate)) return '#3366cc'; + return candidate; +}; + export function RoomScreen() { const [roomHashInput, setRoomHashInput] = useState(''); const [activeRoomHash, setActiveRoomHash] = useState<string | null>(null); - const [firebaseUid, setFirebaseUid] = useState<string | null>(null); + const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null); const [isConnectingFirebase, setIsConnectingFirebase] = useState(false); const [firebaseError, setFirebaseError] = useState<string | null>(null); + const [displayName, setDisplayName] = useState(createDefaultDisplayName()); + const [displayColor, setDisplayColor] = useState('#3366cc'); + const [isRoomSyncEnabled, setIsRoomSyncEnabled] = useState(false); + const [friends, setFriends] = useState<MobileFriendRecord[]>([]); + const [roomSyncError, setRoomSyncError] = useState<string | null>(null); + const [position, setPosition] = useState<LatLon | null>(null); + const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle'); + const [headingDeg, setHeadingDeg] = useState(0); + const parsedRoom = useMemo(() => { if (!activeRoomHash) return null; return parseRoomFromHash(activeRoomHash); }, [activeRoomHash]); + // Load saved profile on first mount + useEffect(() => { + loadProfile().then((saved) => { + if (saved) { + setDisplayName(saved.name); + setDisplayColor(saved.color); + } + }).catch(() => { /* ignore storage errors on load */ }); + }, []); + + // Persist profile whenever name or color changes (debounced 1 s) + useEffect(() => { + const id = setTimeout(() => { + void saveProfile(displayName, displayColor); + }, 1000); + return () => clearTimeout(id); + }, [displayName, displayColor]); + const createRoom = () => { const roomId = randomRoomId(); - setActiveRoomHash(buildRoomHash(roomId)); + const hash = buildRoomHash(roomId); + setActiveRoomHash(hash); setRoomHashInput(''); + + // Log the URL for the web app to join this room + const webAppUrl = `http://localhost:5173/#${hash}`; + console.log(`📱 Room created! Join on web app:\n${webAppUrl}`); + console.log(`Replace localhost:5173 with your web app URL if needed.`); }; const joinFromInput = () => { @@ -52,7 +100,7 @@ export function RoomScreen() { setFirebaseError(null); try { const session = await initFirebaseSession(); - setFirebaseUid(session.uid); + setFirebaseSession(session); } catch (error) { const nextError = error instanceof Error ? error.message : 'Unknown Firebase error'; setFirebaseError(nextError); @@ -61,10 +109,95 @@ export function RoomScreen() { } }; + useEffect(() => { + if (!isRoomSyncEnabled) return; + + let stopWatching: (() => void) | null = null; + + const start = async () => { + const granted = await requestLocationPermission(); + if (!granted) { + setLocationStatus('denied'); + return; + } + setLocationStatus('watching'); + stopWatching = watchPosition( + (nextPosition) => setPosition(nextPosition), + (message) => setRoomSyncError(message) + ); + }; + + void start(); + + return () => { + stopWatching?.(); + setLocationStatus('idle'); + }; + }, [isRoomSyncEnabled]); + + useEffect(() => { + if (!isRoomSyncEnabled) return; + const stop = watchHeading( + (deg) => setHeadingDeg(deg), + (msg) => setRoomSyncError(msg) + ); + return stop; + }, [isRoomSyncEnabled]); + + useEffect(() => { + if (!isRoomSyncEnabled || !firebaseSession || !parsedRoom) { + setFriends([]); + return; + } + + let isDisposed = false; + const roomId = parsedRoom.roomId; + + const writeSelf = async (currentPosition: LatLon) => { + try { + await upsertSelfFriend({ + db: firebaseSession.db, + roomId, + uid: firebaseSession.uid, + name: displayName.trim() || createDefaultDisplayName(), + color: normalizeColor(displayColor), + position: currentPosition + }); + } catch (error) { + const nextError = error instanceof Error ? error.message : 'Unknown room sync write error'; + if (!isDisposed) setRoomSyncError(nextError); + } + }; + + setRoomSyncError(null); + if (position) void writeSelf(position); + + const intervalId = setInterval(() => { + if (position) void writeSelf(position); + }, MOBILE_SYNC_INTERVAL_MS); + + const unsubscribe = subscribeToFriends({ + db: firebaseSession.db, + roomId, + onUpdate: (nextFriends) => { + if (!isDisposed) setFriends(nextFriends); + }, + onError: (error) => { + if (!isDisposed) setRoomSyncError(error.message); + } + }); + + return () => { + isDisposed = true; + clearInterval(intervalId); + unsubscribe(); + }; + }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position]); + return ( <ScrollView contentContainerStyle={styles.container}> <Text style={styles.title}>Friend Compass Mobile</Text> - <Text style={styles.subtitle}>Migration slice: room creation and parsing</Text> + <Text style={styles.subtitle}>Migration slice: room creation and realtime sync</Text> <View style={styles.card}> <Text style={styles.label}>Create a room</Text> @@ -109,13 +242,78 @@ export function RoomScreen() { style={({ pressed }) => [styles.button, (pressed || isConnectingFirebase) && styles.buttonPressed]} > <Text style={styles.buttonText}> - {isConnectingFirebase ? 'Connecting...' : firebaseUid ? 'Reconnect Firebase' : 'Connect Firebase'} + {isConnectingFirebase ? 'Connecting...' : firebaseSession ? 'Reconnect Firebase' : 'Connect Firebase'} </Text> </Pressable> {isConnectingFirebase && <ActivityIndicator color="#7a4f2a" />} - {!!firebaseUid && <Text style={styles.value}>Signed in uid: {firebaseUid}</Text>} + {!!firebaseSession && <Text style={styles.value}>Signed in uid: {firebaseSession.uid}</Text>} {!!firebaseError && <Text style={styles.errorText}>Firebase error: {firebaseError}</Text>} </View> + + <View style={styles.card}> + <Text style={styles.label}>Realtime room sync</Text> + <TextInput + value={displayName} + onChangeText={setDisplayName} + autoCapitalize="none" + autoCorrect={false} + placeholder="display name" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <TextInput + value={displayColor} + onChangeText={setDisplayColor} + autoCapitalize="none" + autoCorrect={false} + placeholder="#3366cc" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <Pressable + onPress={() => setIsRoomSyncEnabled((prev) => !prev)} + disabled={!firebaseSession || !parsedRoom} + style={({ pressed }) => [ + styles.button, + (!firebaseSession || !parsedRoom || pressed) && styles.buttonPressed + ]} + > + <Text style={styles.buttonText}> + {isRoomSyncEnabled ? 'Stop room sync' : 'Start room sync'} + </Text> + </Pressable> + {!firebaseSession && <Text style={styles.valueMuted}>Connect Firebase first.</Text>} + {!!firebaseSession && !parsedRoom && <Text style={styles.valueMuted}>Create or join a room first.</Text>} + {locationStatus === 'denied' && <Text style={styles.errorText}>Location permission denied.</Text>} + {locationStatus === 'watching' && ( + position + ? <Text style={styles.value}>📍 {position.lat.toFixed(5)}, {position.lon.toFixed(5)}</Text> + : <Text style={styles.valueMuted}>Waiting for GPS fix...</Text> + )} + <Text style={styles.value}>Participants in room: {friends.length}</Text> + {friends.map((friend) => { + const bearingDeg = position && friend.position + ? getBearingDeg(position, friend.position) + : null; + const arrowDeg = bearingDeg !== null + ? getRelativeBearingDeg(bearingDeg, headingDeg) + : null; + const dist = position && friend.position + ? formatDistance(getDistanceMeters(position, friend.position)) + : null; + return ( + <View key={friend.id} style={styles.friendRow}> + <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888' }]} /> + <Text style={[styles.value, styles.friendName]}>{friend.name || friend.id}</Text> + {dist !== null && <Text style={styles.friendDist}>{dist}</Text>} + {arrowDeg !== null && ( + <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text> + )} + </View> + ); + })} + {!!roomSyncError && <Text style={styles.errorText}>Room sync error: {roomSyncError}</Text>} + </View> </ScrollView> ); } @@ -184,5 +382,26 @@ const styles = StyleSheet.create({ errorText: { fontSize: 14, color: '#8b2f2f' + }, + friendRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + friendDot: { + width: 12, + height: 12, + borderRadius: 6 + }, + friendName: { + flex: 1 + }, + friendDist: { + fontSize: 13, + color: '#7a7469' + }, + friendArrow: { + fontSize: 24, + color: '#2e2a23' } }); \ No newline at end of file