fuzzyworld

north-star / commit

added qr codes, reorganized ui a bit

b01d9cbcece1f406d0849fcce8625045f903215f · Pavel Safronov · 2026-03-18 16:03:58 -0700 · parents 5d1e23809612

added qr codes, reorganized ui a bit
 mobile/App.tsx                             |  18 +-
 mobile/app.json                            |   6 +-
 mobile/package-lock.json                   | 565 +++++++++++++++++++++++++++++
 mobile/package.json                        |   6 +
 mobile/src/components/qr_scanner_modal.tsx | 164 +++++++++
 mobile/src/platform/profile.ts             |   9 +
 mobile/src/screens/onboarding_screen.tsx   | 230 ++++++++----
 mobile/src/screens/room_screen.tsx         |   5 -
 mobile/src/screens/room_session_screen.tsx | 230 +++++++-----
 9 files changed, 1057 insertions(+), 176 deletions(-)

diff --git a/mobile/App.tsx b/mobile/App.tsx
index 72e6795..ec41f57 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -1,10 +1,11 @@
 import { useEffect, useState } from 'react';
 import { StatusBar } from 'expo-status-bar';
-import { SafeAreaView, StyleSheet, Text, View } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
 import type { SymmetricKey } from './src/core/crypto';
-import { loadProfile, saveProfile } from './src/platform/profile';
+import { loadProfile, saveProfile, loadLastRoom, saveLastRoom } from './src/platform/profile';
 import { OnboardingScreen } from './src/screens/onboarding_screen';
 import { RoomSessionScreen } from './src/screens/room_session_screen';
+import { SafeAreaView } from 'react-native-safe-area-context';
 
 const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`;
 
@@ -12,13 +13,15 @@ export default function App() {
   const [displayName, setDisplayName] = useState(createDefaultDisplayName());
   const [displayColor, setDisplayColor] = useState('#3366cc');
   const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null);
+  const [lastRoomHash, setLastRoomHash] = useState<string | null>(null);
 
   useEffect(() => {
-    loadProfile().then((saved) => {
+    Promise.all([loadProfile(), loadLastRoom()]).then(([saved, lastHash]) => {
       if (saved) {
         setDisplayName(saved.name);
         setDisplayColor(saved.color);
       }
+      if (lastHash) setLastRoomHash(lastHash);
     }).catch(() => { /* ignore storage errors on load */ });
   }, []);
 
@@ -31,6 +34,12 @@ export default function App() {
 
   const isInSession = !!activeRoom;
 
+  const handleStartRoom = (params: { roomHash: string; roomKey: SymmetricKey | null }) => {
+    setLastRoomHash(params.roomHash);
+    void saveLastRoom(params.roomHash);
+    setActiveRoom(params);
+  };
+
   return (
     <SafeAreaView style={styles.root}>
       <StatusBar style="dark" />
@@ -51,7 +60,8 @@ export default function App() {
           displayColor={displayColor}
           onChangeDisplayName={setDisplayName}
           onChangeDisplayColor={setDisplayColor}
-          onStartRoom={setActiveRoom}
+          onStartRoom={handleStartRoom}
+          lastRoomHash={lastRoomHash}
         />
       )}
       {!!activeRoom && (
diff --git a/mobile/app.json b/mobile/app.json
index 8545409..ce66433 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -14,13 +14,15 @@
     "ios": {
       "supportsTablet": true,
       "infoPlist": {
-        "NSLocationWhenInUseUsageDescription": "Friend Compass uses your location to share it with others in your room."
+        "NSLocationWhenInUseUsageDescription": "Friend Compass uses your location to share it with others in your room.",
+        "NSCameraUsageDescription": "Friend Compass uses the camera to scan QR codes for joining rooms."
       }
     },
     "android": {
       "permissions": [
         "ACCESS_FINE_LOCATION",
-        "ACCESS_COARSE_LOCATION"
+        "ACCESS_COARSE_LOCATION",
+        "CAMERA"
       ],
       "adaptiveIcon": {
         "backgroundColor": "#E6F4FE",
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 63711b7..1169940 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -12,12 +12,18 @@
         "@react-native-async-storage/async-storage": "2.2.0",
         "base-64": "^1.0.0",
         "expo": "~54.0.0",
+        "expo-camera": "~17.0.10",
         "expo-location": "~19.0.8",
         "expo-sensors": "~15.0.8",
         "expo-status-bar": "~3.0.9",
         "firebase": "^12.0.0",
         "react": "19.1.0",
+        "react-dom": "19.1.0",
         "react-native": "0.81.5",
+        "react-native-qrcode-svg": "^6.3.21",
+        "react-native-safe-area-context": "^5.7.0",
+        "react-native-svg": "15.12.1",
+        "react-native-web": "^0.21.0",
         "tweetnacl": "^1.0.3"
       },
       "devDependencies": {
@@ -3650,6 +3656,12 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "license": "ISC"
+    },
     "node_modules/bplist-creator": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4051,6 +4063,15 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/cross-fetch": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
+      "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "node-fetch": "^2.7.0"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4065,6 +4086,65 @@
         "node": ">= 8"
       }
     },
+    "node_modules/css-in-js-utils": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
+      "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
+      "license": "MIT",
+      "dependencies": {
+        "hyphenate-style-name": "^1.0.3"
+      }
+    },
+    "node_modules/css-select": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+      "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-what": "^6.1.0",
+        "domhandler": "^5.0.2",
+        "domutils": "^3.0.1",
+        "nth-check": "^2.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/css-tree": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+      "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.0.14",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/css-tree/node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/css-what": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+      "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4089,6 +4169,15 @@
         }
       }
     },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/deep-extend": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -4156,6 +4245,67 @@
         "node": ">=8"
       }
     },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
     "node_modules/dotenv": {
       "version": "16.4.7",
       "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -4210,6 +4360,18 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/env-editor": {
       "version": "0.4.2",
       "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -4338,6 +4500,26 @@
         }
       }
     },
+    "node_modules/expo-camera": {
+      "version": "17.0.10",
+      "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
+      "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
+      "license": "MIT",
+      "dependencies": {
+        "invariant": "^2.2.4"
+      },
+      "peerDependencies": {
+        "expo": "*",
+        "react": "*",
+        "react-native": "*",
+        "react-native-web": "*"
+      },
+      "peerDependenciesMeta": {
+        "react-native-web": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/expo-location": {
       "version": "19.0.8",
       "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz",
@@ -4769,6 +4951,36 @@
         "bser": "2.1.1"
       }
     },
+    "node_modules/fbjs": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
+      "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
+      "license": "MIT",
+      "dependencies": {
+        "cross-fetch": "^3.1.5",
+        "fbjs-css-vars": "^1.0.0",
+        "loose-envify": "^1.0.0",
+        "object-assign": "^4.1.0",
+        "promise": "^7.1.1",
+        "setimmediate": "^1.0.5",
+        "ua-parser-js": "^1.0.35"
+      }
+    },
+    "node_modules/fbjs-css-vars": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
+      "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
+      "license": "MIT"
+    },
+    "node_modules/fbjs/node_modules/promise": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
+      "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
+      "license": "MIT",
+      "dependencies": {
+        "asap": "~2.0.3"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5119,6 +5331,12 @@
         "node": ">= 14"
       }
     },
+    "node_modules/hyphenate-style-name": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
+      "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/idb": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -5201,6 +5419,15 @@
       "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
       "license": "ISC"
     },
+    "node_modules/inline-style-prefixer": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
+      "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
+      "license": "MIT",
+      "dependencies": {
+        "css-in-js-utils": "^3.1.0"
+      }
+    },
     "node_modules/invariant": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -6009,6 +6236,12 @@
       "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
       "license": "Apache-2.0"
     },
+    "node_modules/mdn-data": {
+      "version": "2.0.14",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+      "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
+      "license": "CC0-1.0"
+    },
     "node_modules/memoize-one": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -6464,6 +6697,26 @@
       "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
       "license": "MIT"
     },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-forge": {
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
@@ -6509,6 +6762,18 @@
         "node": "^16.14.0 || >=18.0.0"
       }
     },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
     "node_modules/nullthrows": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -6896,6 +7161,12 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "license": "MIT"
+    },
     "node_modules/pretty-bytes": {
       "version": "5.6.0",
       "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -6974,6 +7245,23 @@
         "node": ">= 6"
       }
     },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/prop-types/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
     "node_modules/protobufjs": {
       "version": "7.5.4",
       "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -7007,6 +7295,23 @@
         "node": ">=6"
       }
     },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/qrcode-terminal": {
       "version": "0.11.0",
       "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz",
@@ -7015,6 +7320,90 @@
         "qrcode-terminal": "bin/qrcode-terminal.js"
       }
     },
+    "node_modules/qrcode/node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qrcode/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/qrcode/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/queue": {
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -7067,6 +7456,18 @@
         "ws": "^7"
       }
     },
+    "node_modules/react-dom": {
+      "version": "19.1.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
+      "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.26.0"
+      },
+      "peerDependencies": {
+        "react": "^19.1.0"
+      }
+    },
     "node_modules/react-is": {
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -7140,6 +7541,79 @@
         "react-native": "*"
       }
     },
+    "node_modules/react-native-qrcode-svg": {
+      "version": "6.3.21",
+      "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",
+      "integrity": "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==",
+      "license": "MIT",
+      "dependencies": {
+        "prop-types": "^15.8.0",
+        "qrcode": "^1.5.4",
+        "text-encoding": "^0.7.0"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-native": ">=0.63.4",
+        "react-native-svg": ">=14.0.0"
+      }
+    },
+    "node_modules/react-native-safe-area-context": {
+      "version": "5.7.0",
+      "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
+      "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "*",
+        "react-native": "*"
+      }
+    },
+    "node_modules/react-native-svg": {
+      "version": "15.12.1",
+      "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
+      "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
+      "license": "MIT",
+      "dependencies": {
+        "css-select": "^5.1.0",
+        "css-tree": "^1.1.3",
+        "warn-once": "0.1.1"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-native": "*"
+      }
+    },
+    "node_modules/react-native-web": {
+      "version": "0.21.2",
+      "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
+      "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.6",
+        "@react-native/normalize-colors": "^0.74.1",
+        "fbjs": "^3.0.4",
+        "inline-style-prefixer": "^7.0.1",
+        "memoize-one": "^6.0.0",
+        "nullthrows": "^1.1.1",
+        "postcss-value-parser": "^4.2.0",
+        "styleq": "^0.1.3"
+      },
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
+      "version": "0.74.89",
+      "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
+      "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
+      "license": "MIT"
+    },
+    "node_modules/react-native-web/node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
     "node_modules/react-native/node_modules/@react-native/virtualized-lists": {
       "version": "0.81.5",
       "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -7310,6 +7784,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
     "node_modules/requireg": {
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz",
@@ -7597,6 +8077,18 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -7818,6 +8310,12 @@
       "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
       "license": "MIT"
     },
+    "node_modules/styleq": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
+      "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
+      "license": "MIT"
+    },
     "node_modules/sucrase": {
       "version": "3.35.1",
       "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -8008,6 +8506,13 @@
         "node": "*"
       }
     },
+    "node_modules/text-encoding": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
+      "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
+      "deprecated": "no longer maintained",
+      "license": "(Unlicense OR Apache-2.0)"
+    },
     "node_modules/thenify": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -8107,6 +8612,12 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
     "node_modules/ts-interface-checker": {
       "version": "0.1.13",
       "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -8157,6 +8668,32 @@
         "node": ">=14.17"
       }
     },
+    "node_modules/ua-parser-js": {
+      "version": "1.0.41",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
+      "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/ua-parser-js"
+        },
+        {
+          "type": "paypal",
+          "url": "https://paypal.me/faisalman"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/faisalman"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "ua-parser-js": "script/cli.js"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/undici": {
       "version": "6.24.1",
       "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
@@ -8302,6 +8839,12 @@
         "makeerror": "1.0.12"
       }
     },
+    "node_modules/warn-once": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
+      "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
+      "license": "MIT"
+    },
     "node_modules/wcwidth": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -8355,6 +8898,16 @@
       "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
       "license": "MIT"
     },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/whatwg-url-without-unicode": {
       "version": "8.0.0-3",
       "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
@@ -8369,6 +8922,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/whatwg-url/node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8384,6 +8943,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
     "node_modules/wonka": {
       "version": "6.3.5",
       "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index e4e3cb7..c2290bd 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -14,12 +14,18 @@
     "@react-native-async-storage/async-storage": "2.2.0",
     "base-64": "^1.0.0",
     "expo": "~54.0.0",
+    "expo-camera": "~17.0.10",
     "expo-location": "~19.0.8",
     "expo-sensors": "~15.0.8",
     "expo-status-bar": "~3.0.9",
     "firebase": "^12.0.0",
     "react": "19.1.0",
+    "react-dom": "19.1.0",
     "react-native": "0.81.5",
+    "react-native-qrcode-svg": "^6.3.21",
+    "react-native-safe-area-context": "^5.7.0",
+    "react-native-svg": "15.12.1",
+    "react-native-web": "^0.21.0",
     "tweetnacl": "^1.0.3"
   },
   "devDependencies": {
diff --git a/mobile/src/components/qr_scanner_modal.tsx b/mobile/src/components/qr_scanner_modal.tsx
new file mode 100644
index 0000000..c973bc0
--- /dev/null
+++ b/mobile/src/components/qr_scanner_modal.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from 'react';
+import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
+import { CameraView, useCameraPermissions } from 'expo-camera';
+
+type QrScannerModalProps = {
+    visible: boolean;
+    onScan: (data: string) => void;
+    onClose: () => void;
+};
+
+export function QrScannerModal({ visible, onScan, onClose }: QrScannerModalProps) {
+    const [permission, requestPermission] = useCameraPermissions();
+    const [scanned, setScanned] = useState(false);
+
+    // Reset scanned state each time the modal opens
+    useEffect(() => {
+        if (visible) setScanned(false);
+    }, [visible]);
+
+    const handleBarcodeScanned = ({ data }: { data: string }) => {
+        if (scanned) return;
+        setScanned(true);
+        onScan(data);
+    };
+
+    const handleRequestPermission = () => {
+        void requestPermission();
+    };
+
+    return (
+        <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
+            <View style={styles.root}>
+                <View style={styles.header}>
+                    <Text style={styles.title}>Scan QR Code</Text>
+                    <Pressable onPress={onClose} style={({ pressed }) => [styles.closeButton, pressed && styles.pressed]}>
+                        <Text style={styles.closeText}>Cancel</Text>
+                    </Pressable>
+                </View>
+
+                {!permission && (
+                    <View style={styles.centeredContent}>
+                        <Text style={styles.message}>Checking camera permission…</Text>
+                    </View>
+                )}
+
+                {permission && !permission.granted && (
+                    <View style={styles.centeredContent}>
+                        <Text style={styles.message}>Camera access is needed to scan QR codes.</Text>
+                        <Pressable
+                            onPress={handleRequestPermission}
+                            style={({ pressed }) => [styles.permissionButton, pressed && styles.pressed]}
+                        >
+                            <Text style={styles.permissionButtonText}>Allow Camera</Text>
+                        </Pressable>
+                    </View>
+                )}
+
+                {permission?.granted && (
+                    <View style={styles.cameraWrap}>
+                        <CameraView
+                            style={styles.camera}
+                            facing="back"
+                            barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
+                            onBarcodeScanned={scanned ? undefined : handleBarcodeScanned}
+                        />
+                        <View style={styles.overlay}>
+                            <View style={styles.scanFrame} />
+                        </View>
+                        <Text style={styles.hint}>
+                            {scanned ? 'Processing…' : 'Point the camera at a Friend Compass QR code'}
+                        </Text>
+                    </View>
+                )}
+            </View>
+        </Modal>
+    );
+}
+
+const styles = StyleSheet.create({
+    root: {
+        flex: 1,
+        backgroundColor: '#111'
+    },
+    header: {
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+        alignItems: 'center',
+        paddingHorizontal: 20,
+        paddingTop: 60,
+        paddingBottom: 16,
+        backgroundColor: '#1a1a1a'
+    },
+    title: {
+        fontSize: 18,
+        fontWeight: '700',
+        color: '#f2ede5'
+    },
+    closeButton: {
+        paddingHorizontal: 14,
+        paddingVertical: 8,
+        borderRadius: 8,
+        backgroundColor: '#333'
+    },
+    closeText: {
+        color: '#f2ede5',
+        fontSize: 14,
+        fontWeight: '600'
+    },
+    pressed: {
+        opacity: 0.7
+    },
+    centeredContent: {
+        flex: 1,
+        justifyContent: 'center',
+        alignItems: 'center',
+        gap: 20,
+        padding: 32
+    },
+    message: {
+        color: '#ccc',
+        fontSize: 15,
+        textAlign: 'center'
+    },
+    permissionButton: {
+        backgroundColor: '#7a4f2a',
+        borderRadius: 10,
+        paddingHorizontal: 24,
+        paddingVertical: 12
+    },
+    permissionButtonText: {
+        color: '#f7eee2',
+        fontSize: 15,
+        fontWeight: '700'
+    },
+    cameraWrap: {
+        flex: 1,
+        position: 'relative'
+    },
+    camera: {
+        flex: 1
+    },
+    overlay: {
+        ...StyleSheet.absoluteFillObject,
+        justifyContent: 'center',
+        alignItems: 'center'
+    },
+    scanFrame: {
+        width: 220,
+        height: 220,
+        borderRadius: 12,
+        borderWidth: 2,
+        borderColor: 'rgba(255, 220, 160, 0.85)'
+    },
+    hint: {
+        position: 'absolute',
+        bottom: 48,
+        left: 0,
+        right: 0,
+        textAlign: 'center',
+        color: '#f2ede5',
+        fontSize: 14,
+        paddingHorizontal: 24
+    }
+});
diff --git a/mobile/src/platform/profile.ts b/mobile/src/platform/profile.ts
index 34984d9..208bec8 100644
--- a/mobile/src/platform/profile.ts
+++ b/mobile/src/platform/profile.ts
@@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
 
 const KEY_NAME = 'profile:name';
 const KEY_COLOR = 'profile:color';
+const KEY_LAST_ROOM = 'room:last_hash';
 
 export const loadProfile = async (): Promise<{ name: string; color: string } | null> => {
     const [name, color] = await AsyncStorage.multiGet([KEY_NAME, KEY_COLOR]);
@@ -17,3 +18,11 @@ export const saveProfile = async (name: string, color: string): Promise<void> =>
         [KEY_COLOR, color],
     ]);
 };
+
+export const loadLastRoom = async (): Promise<string | null> => {
+    return AsyncStorage.getItem(KEY_LAST_ROOM);
+};
+
+export const saveLastRoom = async (roomHash: string): Promise<void> => {
+    await AsyncStorage.setItem(KEY_LAST_ROOM, roomHash);
+};
diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx
index 2087e99..7c2070b 100644
--- a/mobile/src/screens/onboarding_screen.tsx
+++ b/mobile/src/screens/onboarding_screen.tsx
@@ -1,8 +1,16 @@
 import { useState } from 'react';
 import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+
+const PRESET_COLORS = [
+    '#e53935', '#f06292', '#ab47bc', '#7986cb', '#42a5f5',
+    '#26c6da', '#26a69a', '#66bb6a', '#d4e157', '#ffca28',
+    '#ffa726', '#ff7043', '#8d6e63', '#78909c', '#546e7a',
+    '#3366cc', '#6a1b9a', '#00695c', '#558b2f', '#4e342e',
+];
 import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto';
 import { buildRoomHash, parseRoomFromHash } from '../core/room';
 import { PUBLIC_SETTINGS } from '../public_settings';
+import { QrScannerModal } from '../components/qr_scanner_modal';
 
 type OnboardingScreenProps = {
     displayName: string;
@@ -10,6 +18,7 @@ type OnboardingScreenProps = {
     onChangeDisplayName: (name: string) => void;
     onChangeDisplayColor: (color: string) => void;
     onStartRoom: (params: { roomHash: string; roomKey: SymmetricKey | null }) => void;
+    lastRoomHash: string | null;
 };
 
 const randomRoomId = () => {
@@ -26,10 +35,12 @@ export function OnboardingScreen({
     displayColor,
     onChangeDisplayName,
     onChangeDisplayColor,
-    onStartRoom
+    onStartRoom,
+    lastRoomHash
 }: OnboardingScreenProps) {
-    const [roomHashInput, setRoomHashInput] = useState('');
     const [joinError, setJoinError] = useState<string | null>(null);
+    const [rejoinError, setRejoinError] = useState<string | null>(null);
+    const [showScanner, setShowScanner] = useState(false);
 
     const createRoom = async () => {
         setJoinError(null);
@@ -39,91 +50,120 @@ export function OnboardingScreen({
         const hash = buildRoomHash(roomId, keyB64);
 
         const webAppUrl = `http://localhost:5173/#${hash}`;
-        console.log(`Room created. Join from web: ${webAppUrl}`);
 
         onStartRoom({ roomHash: hash, roomKey: key });
     };
 
-    const joinRoom = async () => {
-        setJoinError(null);
-        const normalized = roomHashInput.trim().replace(/^#/, '');
+    // Accept either a plain room hash or a full URL (as produced by the web app QR codes)
+    const extractHash = (raw: string): string => {
+        const trimmed = raw.trim();
+        try {
+            const url = new URL(trimmed);
+            return url.hash.replace(/^#/, '');
+        } catch {
+            return trimmed.replace(/^#/, '');
+        }
+    };
+
+    const joinRoomFromHash = async (rawHash: string, setError: (e: string | null) => void) => {
+        setError(null);
+        const normalized = extractHash(rawHash);
         const parsed = parseRoomFromHash(normalized);
         if (!parsed) {
-            setJoinError('Invalid room hash.');
+            setError('Invalid room hash.');
             return;
         }
-
         const hash = buildRoomHash(parsed.roomId, parsed.roomKeyBase64);
         const roomKey = parsed.roomKeyBase64 ? await importRoomKey(parsed.roomKeyBase64) : null;
         onStartRoom({ roomHash: hash, roomKey });
     };
 
+    const rejoinRoom = () => void joinRoomFromHash(lastRoomHash ?? '', setRejoinError);
+    const handleQrScanned = (data: string) => {
+        setShowScanner(false);
+        void joinRoomFromHash(data, setJoinError);
+    };
+
     return (
-        <ScrollView contentContainerStyle={styles.container}>
-            <View style={styles.heroCard}>
-                <View style={styles.heroOrbA} />
-                <View style={styles.heroOrbB} />
-                <Text style={styles.heroEyebrow}>NORTH STAR</Text>
-                <Text style={styles.title}>Friend Compass</Text>
-                <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text>
-            </View>
+        <>
+            <QrScannerModal
+                visible={showScanner}
+                onScan={handleQrScanned}
+                onClose={() => setShowScanner(false)}
+            />
+            <ScrollView contentContainerStyle={styles.container}>
+                <View style={styles.heroCard}>
+                    <View style={styles.heroOrbA} />
+                    <View style={styles.heroOrbB} />
+                    <Text style={styles.title}>Friend Compass</Text>
+                    <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text>
+                </View>
 
-            <View style={styles.card}>
-                <View style={styles.profileHeaderRow}>
-                    <Text style={styles.label}>Step 1: Your profile</Text>
-                    <View style={[styles.colorPreview, { backgroundColor: displayColor || '#3366cc' }]} />
+                <View style={styles.card}>
+                    <Text style={styles.label}>Your Profile</Text>
+                    <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text>
+                    <TextInput
+                        value={displayName}
+                        onChangeText={onChangeDisplayName}
+                        autoCapitalize="none"
+                        autoCorrect={false}
+                        placeholder="display name"
+                        placeholderTextColor="#7a7469"
+                        style={styles.input}
+                    />
+                    <View style={styles.colorPickerRow}>
+                        <View style={[styles.colorPreviewLarge, { backgroundColor: displayColor || '#3366cc' }]} />
+                        <View style={styles.colorGrid}>
+                            {PRESET_COLORS.map((color) => (
+                                <Pressable
+                                    key={color}
+                                    onPress={() => onChangeDisplayColor(color)}
+                                    style={[
+                                        styles.colorSwatch,
+                                        { backgroundColor: color },
+                                        displayColor === color && styles.colorSwatchSelected
+                                    ]}
+                                />
+                            ))}
+                        </View>
+                    </View>
                 </View>
-                <TextInput
-                    value={displayName}
-                    onChangeText={onChangeDisplayName}
-                    autoCapitalize="none"
-                    autoCorrect={false}
-                    placeholder="display name"
-                    placeholderTextColor="#7a7469"
-                    style={styles.input}
-                />
-                <TextInput
-                    value={displayColor}
-                    onChangeText={onChangeDisplayColor}
-                    autoCapitalize="none"
-                    autoCorrect={false}
-                    placeholder="#3366cc"
-                    placeholderTextColor="#7a7469"
-                    style={styles.input}
-                />
-                <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text>
-            </View>
 
-            <View style={styles.createCard}>
-                <Text style={styles.label}>Step 2A: Create room</Text>
-                <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text>
-                <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}>
-                    <Text style={styles.buttonText}>Create encrypted room</Text>
-                </Pressable>
-            </View>
+                {!!lastRoomHash && (
+                    <View style={styles.rejoinCard}>
+                        <Text style={styles.label}>Rejoin Last Room</Text>
+                        <Text style={styles.helperText}>You were in a room recently. Jump back in.</Text>
+                        <Pressable onPress={rejoinRoom} style={({ pressed }) => [styles.buttonRejoin, pressed && styles.buttonPressed]}>
+                            <Text style={styles.buttonText}>Rejoin Room</Text>
+                        </Pressable>
+                        {!!rejoinError && <Text style={styles.errorText}>{rejoinError}</Text>}
+                    </View>
+                )}
 
-            <View style={styles.joinCard}>
-                <Text style={styles.label}>Step 2B: Join existing room</Text>
-                <Text style={styles.helperText}>Paste an invite hash from web or another phone.</Text>
-                <TextInput
-                    value={roomHashInput}
-                    onChangeText={setRoomHashInput}
-                    autoCapitalize="none"
-                    autoCorrect={false}
-                    placeholder="example: ab12cd34"
-                    placeholderTextColor="#7a7469"
-                    style={styles.input}
-                />
-                <Pressable onPress={() => void joinRoom()} style={({ pressed }) => [styles.buttonSecondary, pressed && styles.buttonPressed]}>
-                    <Text style={styles.buttonText}>Join room</Text>
-                </Pressable>
-                {!!joinError && <Text style={styles.errorText}>{joinError}</Text>}
-            </View>
+                <View style={styles.joinCard}>
+                    <Text style={styles.label}>Join Existing Room</Text>
+                    <Text style={styles.helperText}>Scan a QR code from the web app or another phone.</Text>
+                    <Pressable
+                        onPress={() => setShowScanner(true)}
+                        style={({ pressed }) => [styles.buttonQrScan, pressed && styles.buttonPressed]}
+                    >
+                        <Text style={styles.buttonText}>📷  Scan QR code</Text>
+                    </Pressable>
+                    {!!joinError && <Text style={styles.errorText}>{joinError}</Text>}
+                </View>
 
-            <Text style={styles.footerNote}>Tip: after creating a room, the app logs a web URL in the console for quick laptop join.</Text>
-        </ScrollView>
+                <View style={styles.createCard}>
+                    <Text style={styles.label}>Create Room</Text>
+                    <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text>
+                    <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}>
+                        <Text style={styles.buttonText}>Create Room</Text>
+                    </Pressable>
+                </View>
+            </ScrollView>
+        </>
     );
-}
+};
+
 
 const styles = StyleSheet.create({
     container: {
@@ -189,6 +229,14 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#cac0ac'
     },
+    rejoinCard: {
+        backgroundColor: '#e0ddf5',
+        borderRadius: 14,
+        padding: 16,
+        gap: 10,
+        borderWidth: 1,
+        borderColor: '#b8b3e0'
+    },
     joinCard: {
         backgroundColor: '#e5d8c4',
         borderRadius: 14,
@@ -197,17 +245,35 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#ccb798'
     },
-    profileHeaderRow: {
+    colorPickerRow: {
         flexDirection: 'row',
-        justifyContent: 'space-between',
-        alignItems: 'center'
+        alignItems: 'flex-start',
+        gap: 12
     },
-    colorPreview: {
-        width: 18,
-        height: 18,
-        borderRadius: 999,
-        borderWidth: 1,
-        borderColor: '#8f8778'
+    colorPreviewLarge: {
+        width: 44,
+        height: 44,
+        borderRadius: 22,
+        borderWidth: 2,
+        borderColor: '#8f8778',
+        flexShrink: 0
+    },
+    colorGrid: {
+        flex: 1,
+        flexDirection: 'row',
+        flexWrap: 'wrap',
+        gap: 8
+    },
+    colorSwatch: {
+        width: 28,
+        height: 28,
+        borderRadius: 14,
+        borderWidth: 2,
+        borderColor: 'transparent'
+    },
+    colorSwatchSelected: {
+        borderColor: '#2e2a23',
+        transform: [{ scale: 1.18 }]
     },
     label: {
         fontSize: 14,
@@ -237,6 +303,18 @@ const styles = StyleSheet.create({
         alignItems: 'center',
         paddingVertical: 12
     },
+    buttonQrScan: {
+        backgroundColor: '#3d5a8a',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 12
+    },
+    buttonRejoin: {
+        backgroundColor: '#4a4a8a',
+        borderRadius: 10,
+        alignItems: 'center',
+        paddingVertical: 12
+    },
     buttonPressed: {
         opacity: 0.85
     },
diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx
index a2b9021..b217828 100644
--- a/mobile/src/screens/room_screen.tsx
+++ b/mobile/src/screens/room_screen.tsx
@@ -86,11 +86,6 @@ export function RoomScreen() {
         const hash = buildRoomHash(roomId, keyB64);
         setActiveRoomHash(hash);
         setRoomHashInput('');
-
-        // Log the URL for the web app to join this room
-        const webAppUrl = `http://localhost:5173/#${hash}`;
-        console.log(`📱 Encrypted room created! Join on web app:\n${webAppUrl}`);
-        console.log(`Replace localhost:5173 with your web app URL if needed.`);
     };
 
     const joinFromInput = async () => {
diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx
index be5e840..6bb2efd 100644
--- a/mobile/src/screens/room_session_screen.tsx
+++ b/mobile/src/screens/room_session_screen.tsx
@@ -7,6 +7,7 @@ import {
     Text,
     View
 } from 'react-native';
+import QRCode from 'react-native-qrcode-svg';
 import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing';
 import { parseRoomFromHash } from '../core/room';
 import type { SymmetricKey } from '../core/crypto';
@@ -14,6 +15,7 @@ import { watchHeading } from '../platform/heading';
 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;
 
@@ -41,35 +43,55 @@ export function RoomSessionScreen({
     onLeaveRoom
 }: RoomSessionScreenProps) {
     const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null);
-    const [isConnectingFirebase, setIsConnectingFirebase] = useState(false);
-    const [firebaseError, setFirebaseError] = useState<string | null>(null);
-
-    const [isRoomSyncEnabled, setIsRoomSyncEnabled] = useState(false);
     const [friends, setFriends] = useState<MobileFriendRecord[]>([]);
-    const [roomSyncError, setRoomSyncError] = useState<string | null>(null);
+    const [syncError, setSyncError] = 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 [showQr, setShowQr] = useState(false);
+    const [customOrder, setCustomOrder] = useState<string[]>([]);
 
     const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]);
+    const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`;
 
-    const connectFirebase = async () => {
-        setIsConnectingFirebase(true);
-        setFirebaseError(null);
-        try {
-            const session = await initFirebaseSession();
-            setFirebaseSession(session);
-        } catch (error) {
-            const nextError = error instanceof Error ? error.message : 'Unknown Firebase error';
-            setFirebaseError(nextError);
-        } finally {
-            setIsConnectingFirebase(false);
-        }
+    const orderedFriends = useMemo(() => {
+        const knownIds = customOrder.filter(id => friends.some(f => f.id === id));
+        const newFriends = friends.filter(f => !customOrder.includes(f.id));
+        return [
+            ...knownIds.map(id => friends.find(f => f.id === id)!),
+            ...newFriends
+        ];
+    }, [friends, customOrder]);
+
+    const moveUp = (index: number) => {
+        if (index <= 0) return;
+        const ids = orderedFriends.map(f => f.id);
+        const next = [...ids];
+        [next[index - 1], next[index]] = [next[index], next[index - 1]];
+        setCustomOrder(next);
     };
 
+    const moveDown = (index: number) => {
+        if (index >= orderedFriends.length - 1) return;
+        const ids = orderedFriends.map(f => f.id);
+        const next = [...ids];
+        [next[index], next[index + 1]] = [next[index + 1], next[index]];
+        setCustomOrder(next);
+    };
+
+    // Auto-connect Firebase on mount
     useEffect(() => {
-        if (!isRoomSyncEnabled) return;
+        let disposed = false;
+        initFirebaseSession()
+            .then((session) => { if (!disposed) setFirebaseSession(session); })
+            .catch((error) => {
+                if (!disposed) setSyncError(error instanceof Error ? error.message : 'Firebase connection failed');
+            });
+        return () => { disposed = true; };
+    }, []);
 
+    // Start location watching on mount
+    useEffect(() => {
         let stopWatching: (() => void) | null = null;
 
         const start = async () => {
@@ -81,7 +103,7 @@ export function RoomSessionScreen({
             setLocationStatus('watching');
             stopWatching = watchPosition(
                 (nextPosition) => setPosition(nextPosition),
-                (message) => setRoomSyncError(message)
+                (message) => setSyncError(message)
             );
         };
 
@@ -91,19 +113,19 @@ export function RoomSessionScreen({
             stopWatching?.();
             setLocationStatus('idle');
         };
-    }, [isRoomSyncEnabled]);
+    }, []);
 
+    // Start heading watching on mount
     useEffect(() => {
-        if (!isRoomSyncEnabled) return;
-        const stop = watchHeading(
+        return watchHeading(
             (deg) => setHeadingDeg(deg),
-            (msg) => setRoomSyncError(msg)
+            (msg) => setSyncError(msg)
         );
-        return stop;
-    }, [isRoomSyncEnabled]);
+    }, []);
 
+    // Firebase sync when session is ready
     useEffect(() => {
-        if (!isRoomSyncEnabled || !firebaseSession || !parsedRoom) {
+        if (!firebaseSession || !parsedRoom) {
             setFriends([]);
             return;
         }
@@ -124,11 +146,11 @@ export function RoomSessionScreen({
                 });
             } catch (error) {
                 const nextError = error instanceof Error ? error.message : 'Unknown room sync write error';
-                if (!isDisposed) setRoomSyncError(nextError);
+                if (!isDisposed) setSyncError(nextError);
             }
         };
 
-        setRoomSyncError(null);
+        setSyncError(null);
         if (position) void writeSelf(position);
 
         const intervalId = setInterval(() => {
@@ -143,7 +165,7 @@ export function RoomSessionScreen({
                 if (!isDisposed) setFriends(nextFriends);
             },
             onError: (error) => {
-                if (!isDisposed) setRoomSyncError(error.message);
+                if (!isDisposed) setSyncError(error.message);
             }
         });
 
@@ -152,7 +174,16 @@ export function RoomSessionScreen({
             clearInterval(intervalId);
             unsubscribe();
         };
-    }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]);
+    }, [displayColor, displayName, firebaseSession, parsedRoom, position, roomKey]);
+
+    // Absorb newly-seen friends into the custom order
+    useEffect(() => {
+        setCustomOrder(prev => {
+            const newIds = friends.filter(f => !prev.includes(f.id)).map(f => f.id);
+            if (newIds.length === 0) return prev;
+            return [...prev, ...newIds];
+        });
+    }, [friends]);
 
     return (
         <ScrollView contentContainerStyle={styles.container}>
@@ -169,54 +200,39 @@ export function RoomSessionScreen({
                 </View>
             </View>
 
-            <View style={styles.resultCard}>
-                <Text style={styles.label}>Live room details</Text>
-                <View>
-                    <Text style={styles.value}>Hash: {roomHash}</Text>
-                    <Text style={styles.value}>Encryption: {roomKey ? 'enabled' : 'none'}</Text>
-                </View>
-            </View>
-
             <View style={styles.card}>
-                <Text style={styles.label}>Firebase session</Text>
                 <Pressable
-                    onPress={connectFirebase}
-                    disabled={isConnectingFirebase}
-                    style={({ pressed }) => [styles.button, (pressed || isConnectingFirebase) && styles.buttonPressed]}
+                    onPress={() => setShowQr((v) => !v)}
+                    style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]}
                 >
-                    <Text style={styles.buttonText}>
-                        {isConnectingFirebase ? 'Connecting...' : firebaseSession ? 'Reconnect Firebase' : 'Connect Firebase'}
-                    </Text>
+                    <Text style={styles.label}>Invite others</Text>
+                    <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text>
                 </Pressable>
-                {isConnectingFirebase && <ActivityIndicator color="#7a4f2a" />}
-                {!!firebaseSession && <Text style={styles.value}>Signed in uid: {firebaseSession.uid}</Text>}
-                {!!firebaseError && <Text style={styles.errorText}>Firebase error: {firebaseError}</Text>}
+                {showQr && (
+                    <View style={styles.qrWrap}>
+                        <QRCode value={inviteUrl} size={200} />
+                        <Text style={styles.valueMuted}>Scan to join on web or mobile</Text>
+                    </View>
+                )}
             </View>
 
             <View style={styles.card}>
-                <Text style={styles.label}>Live sync</Text>
-                <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}>Invalid room hash.</Text>}
-                {locationStatus === 'denied' && <Text style={styles.errorText}>Location permission denied.</Text>}
-                {locationStatus === 'watching' && (
-                    position
-                        ? <Text style={styles.value}>Location: {position.lat.toFixed(5)}, {position.lon.toFixed(5)}</Text>
-                        : <Text style={styles.valueMuted}>Waiting for GPS fix...</Text>
+                <Text style={styles.label}>
+                    {orderedFriends.length > 0 ? `Friends (${orderedFriends.length})` : 'Friends'}
+                </Text>
+                {!firebaseSession && !syncError && (
+                    <View style={styles.connectingRow}>
+                        <ActivityIndicator size="small" color="#7a4f2a" />
+                        <Text style={styles.valueMuted}>Connecting to room...</Text>
+                    </View>
                 )}
-                <Text style={styles.value}>Participants in room: {friends.length}</Text>
-                {friends.map((friend) => {
+                {locationStatus === 'denied' && (
+                    <Text style={styles.errorText}>Location permission denied.</Text>
+                )}
+                {locationStatus === 'watching' && !position && (
+                    <Text style={styles.valueMuted}>Waiting for GPS fix...</Text>
+                )}
+                {orderedFriends.map((friend, index) => {
                     const bearingDeg = position && friend.position
                         ? getBearingDeg(position, friend.position)
                         : null;
@@ -234,10 +250,28 @@ export function RoomSessionScreen({
                             {arrowDeg !== null && (
                                 <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text>
                             )}
+                            {orderedFriends.length > 1 && (
+                                <View style={styles.moveButtons}>
+                                    <Pressable
+                                        onPress={() => moveUp(index)}
+                                        disabled={index === 0}
+                                        style={[styles.moveButton, index === 0 && styles.moveButtonDim]}
+                                    >
+                                        <Text style={styles.moveButtonText}>▲</Text>
+                                    </Pressable>
+                                    <Pressable
+                                        onPress={() => moveDown(index)}
+                                        disabled={index === orderedFriends.length - 1}
+                                        style={[styles.moveButton, index === orderedFriends.length - 1 && styles.moveButtonDim]}
+                                    >
+                                        <Text style={styles.moveButtonText}>▼</Text>
+                                    </Pressable>
+                                </View>
+                            )}
                         </View>
                     );
                 })}
-                {!!roomSyncError && <Text style={styles.errorText}>Room sync error: {roomSyncError}</Text>}
+                {!!syncError && <Text style={styles.errorText}>{syncError}</Text>}
             </View>
         </ScrollView>
     );
@@ -289,25 +323,30 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#d7ccb9'
     },
-    resultCard: {
-        backgroundColor: '#dbe3df',
-        borderRadius: 14,
-        padding: 16,
-        gap: 6,
-        borderWidth: 1,
-        borderColor: '#c2d1cb'
+    connectingRow: {
+        flexDirection: 'row',
+        alignItems: 'center',
+        gap: 8
+    },
+    qrToggleRow: {
+        flexDirection: 'row',
+        justifyContent: 'space-between',
+        alignItems: 'center'
+    },
+    qrToggleChevron: {
+        fontSize: 12,
+        color: '#5b554c'
+    },
+    qrWrap: {
+        alignItems: 'center',
+        gap: 10,
+        paddingTop: 4
     },
     label: {
         fontSize: 14,
         fontWeight: '600',
         color: '#2e2a23'
     },
-    button: {
-        backgroundColor: '#7a4f2a',
-        borderRadius: 10,
-        alignItems: 'center',
-        paddingVertical: 12
-    },
     secondaryButton: {
         borderWidth: 1,
         borderColor: '#7a4f2a',
@@ -324,11 +363,6 @@ const styles = StyleSheet.create({
     buttonPressed: {
         opacity: 0.85
     },
-    buttonText: {
-        color: '#f7eee2',
-        fontSize: 15,
-        fontWeight: '700'
-    },
     value: {
         fontSize: 14,
         color: '#2e2a23'
@@ -361,5 +395,23 @@ const styles = StyleSheet.create({
     friendArrow: {
         fontSize: 24,
         color: '#2e2a23'
+    },
+    moveButtons: {
+        flexDirection: 'column',
+        gap: 2
+    },
+    moveButton: {
+        paddingHorizontal: 6,
+        paddingVertical: 3,
+        borderRadius: 4,
+        backgroundColor: '#d7ccb9'
+    },
+    moveButtonDim: {
+        opacity: 0.3
+    },
+    moveButtonText: {
+        fontSize: 10,
+        color: '#2e2a23',
+        lineHeight: 12
     }
 });