fuzzyworld

north-star / commit

getting firebase stuff working

70c9195cdb5af03543cef176f499c380edc1ee88 · Pavel Safronov · 2026-03-16 20:52:30 -0700 · parents 3a20a898de94

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