north-star / commit
got north working, and a few minor changes
mobile/App.tsx | 71 +++++----------- mobile/src/platform/heading.ts | 65 ++++++++++----- mobile/src/screens/room_session_screen.tsx | 128 ++++++++++++++++++++--------- 3 files changed, 153 insertions(+), 111 deletions(-) diff --git a/mobile/App.tsx b/mobile/App.tsx index ec41f57..38a5165 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { SymmetricKey } from './src/core/crypto'; 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'; +import { requestLocationPermission, watchPosition, type LatLon } from './src/platform/geolocation'; const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`; @@ -14,6 +15,8 @@ export default function App() { const [displayColor, setDisplayColor] = useState('#3366cc'); const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null); const [lastRoomHash, setLastRoomHash] = useState<string | null>(null); + const [position, setPosition] = useState<LatLon | null>(null); + const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle'); useEffect(() => { Promise.all([loadProfile(), loadLastRoom()]).then(([saved, lastHash]) => { @@ -25,6 +28,19 @@ export default function App() { }).catch(() => { /* ignore storage errors on load */ }); }, []); + // Acquire GPS as soon as the app opens so it is ready when entering a room + useEffect(() => { + let stopWatching: (() => void) | null = null; + const start = async () => { + const granted = await requestLocationPermission(); + if (!granted) { setLocationStatus('denied'); return; } + setLocationStatus('watching'); + stopWatching = watchPosition(setPosition, () => { /* surfaced in room screen */ }); + }; + void start(); + return () => { stopWatching?.(); }; + }, []); + useEffect(() => { const id = setTimeout(() => { void saveProfile(displayName, displayColor); @@ -32,8 +48,6 @@ export default function App() { return () => clearTimeout(id); }, [displayName, displayColor]); - const isInSession = !!activeRoom; - const handleStartRoom = (params: { roomHash: string; roomKey: SymmetricKey | null }) => { setLastRoomHash(params.roomHash); void saveLastRoom(params.roomHash); @@ -43,17 +57,6 @@ export default function App() { return ( <SafeAreaView style={styles.root}> <StatusBar style="dark" /> - <View style={styles.progressWrap}> - <View style={styles.progressHeaderRow}> - <View style={[styles.stepPill, !isInSession && styles.stepPillActive]}> - <Text style={[styles.stepPillText, !isInSession && styles.stepPillTextActive]}>1. Setup</Text> - </View> - <View style={[styles.stepConnector, isInSession && styles.stepConnectorActive]} /> - <View style={[styles.stepPill, isInSession && styles.stepPillActive]}> - <Text style={[styles.stepPillText, isInSession && styles.stepPillTextActive]}>2. Live Room</Text> - </View> - </View> - </View> {!activeRoom && ( <OnboardingScreen displayName={displayName} @@ -70,6 +73,8 @@ export default function App() { roomKey={activeRoom.roomKey} displayName={displayName} displayColor={displayColor} + position={position} + locationStatus={locationStatus} onLeaveRoom={() => setActiveRoom(null)} /> )} @@ -81,43 +86,5 @@ const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#f5f1ea' - }, - progressWrap: { - paddingHorizontal: 20, - paddingTop: 12, - paddingBottom: 8, - backgroundColor: '#efe8d9', - borderBottomWidth: 1, - borderBottomColor: '#ddd2bc' - }, - progressHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8 - }, - stepPill: { - borderRadius: 999, - backgroundColor: '#d9cfbb', - paddingHorizontal: 12, - paddingVertical: 6 - }, - stepPillActive: { - backgroundColor: '#7a4f2a' - }, - stepPillText: { - color: '#5f5648', - fontSize: 12, - fontWeight: '700' - }, - stepPillTextActive: { - color: '#f6ead9' - }, - stepConnector: { - height: 2, - flex: 1, - backgroundColor: '#cdbfa6' - }, - stepConnectorActive: { - backgroundColor: '#7a4f2a' } }); diff --git a/mobile/src/platform/heading.ts b/mobile/src/platform/heading.ts index 65bee36..858f716 100644 --- a/mobile/src/platform/heading.ts +++ b/mobile/src/platform/heading.ts @@ -1,33 +1,54 @@ -import { Magnetometer } from 'expo-sensors'; +import * as Location from 'expo-location'; + +// Lower alpha = smoother but slower to track movement. 0.15 is a good balance. +const SMOOTH_ALPHA = 0.15; /** - * Watches the device magnetometer and calls onHeading with the - * compass bearing in degrees (0 = north, 90 = east, etc.). + * Watches device heading using the platform's tilt-compensated compass + * (CoreLocation on iOS, SensorManager fusion on Android). + * Raw magnetometer atan2 breaks when the phone is not horizontal; + * this API handles all tilt cases correctly at the OS level. + * + * Applies circular EMA smoothing (via sin/cos) to reduce jitter. * Returns an unsubscribe function. */ export const watchHeading = ( onHeading: (headingDeg: number) => void, - onError: (message: string) => void + onError: (message: string) => void, + onRawHeading?: (rawDeg: number) => void ): (() => void) => { - Magnetometer.setUpdateInterval(200); + let sSin = 0; + let sCos = 1; + let initialized = false; + let subscription: Location.LocationSubscription | null = null; + let disposed = false; - 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); - }); + Location.watchHeadingAsync((locationHeading) => { + const rawDeg = locationHeading.magHeading; + onRawHeading?.(rawDeg); - Magnetometer.isAvailableAsync() - .then((available) => { - if (!available) onError('Magnetometer unavailable on this device'); - }) - .catch(() => { - onError('Could not check magnetometer availability'); - }); + const rad = rawDeg * Math.PI / 180; + if (!initialized) { + sSin = Math.sin(rad); + sCos = Math.cos(rad); + initialized = true; + } else { + sSin = SMOOTH_ALPHA * Math.sin(rad) + (1 - SMOOTH_ALPHA) * sSin; + sCos = SMOOTH_ALPHA * Math.cos(rad) + (1 - SMOOTH_ALPHA) * sCos; + } + + const smoothedDeg = ((Math.atan2(sSin, sCos) * 180 / Math.PI) % 360 + 360) % 360; + onHeading(smoothedDeg); + }).then((sub) => { + if (disposed) sub.remove(); + else subscription = sub; + }).catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Could not watch device heading'; + onError(message); + }); - return () => subscription.remove(); + return () => { + disposed = true; + subscription?.remove(); + }; }; diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx index 112411d..488c9d8 100644 --- a/mobile/src/screens/room_session_screen.tsx +++ b/mobile/src/screens/room_session_screen.tsx @@ -4,29 +4,37 @@ import { Image, Pressable, ScrollView, + Share, StyleSheet, Text, View } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Magnetometer, Accelerometer } from 'expo-sensors'; import QRCode from 'react-native-qrcode-svg'; import { ArrowsPanel, type ArrowFriend } from '../components/arrows_panel'; -import { getBearingDeg, getRelativeBearingDeg } from '../core/bearing'; +import { getBearingDeg, getRelativeBearingDeg, normalizeAngle } from '../core/bearing'; import { parseRoomFromHash } from '../core/room'; import type { SymmetricKey } from '../core/crypto'; 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 type { LatLon } from '../platform/geolocation'; import { PUBLIC_SETTINGS } from '../public_settings'; const MOBILE_SYNC_INTERVAL_MS = 10000; +const NORTH_FRIEND_ID = '__north__'; +const NORTH_COLOR = '#000000'; +// 100 yards in latitude degrees (~91.44 m) +const NORTH_YARDS_LAT_OFFSET = 91.44 / 111320; type RoomSessionScreenProps = { roomHash: string; roomKey: SymmetricKey | null; displayName: string; displayColor: string; + position: LatLon | null; + locationStatus: 'idle' | 'denied' | 'watching'; onLeaveRoom: () => void; }; @@ -43,32 +51,52 @@ export function RoomSessionScreen({ roomKey, displayName, displayColor, + position, + locationStatus, onLeaveRoom }: RoomSessionScreenProps) { const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null); const [friends, setFriends] = useState<MobileFriendRecord[]>([]); 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 [headingOffset, setHeadingOffset] = useState(0); + const [rawHeadingDeg, setRawHeadingDeg] = useState<number | null>(null); const [showQr, setShowQr] = useState(false); const [customOrder, setCustomOrder] = useState<string[]>([]); const [hiddenFriendIds, setHiddenFriendIds] = useState<Set<string>>(new Set()); + const [rawMag, setRawMag] = useState<{ x: number; y: number; z: number } | null>(null); + const [rawAccel, setRawAccel] = useState<{ x: number; y: number; z: number } | null>(null); const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]); const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`; const hiddenStorageKey = parsedRoom ? `friends:hidden:${parsedRoom.roomId}` : null; + // Virtual "North" friend — always 100 yards north of current position + const northVirtualFriend = useMemo((): MobileFriendRecord => ({ + id: NORTH_FRIEND_ID, + name: 'North', + color: NORTH_COLOR, + position: position + ? { lat: position.lat + NORTH_YARDS_LAT_OFFSET, lon: position.lon } + : undefined + }), [position]); + + const allFriends = useMemo( + () => [northVirtualFriend, ...friends], + [northVirtualFriend, friends] + ); + const orderedFriends = useMemo(() => { - const knownIds = customOrder.filter(id => friends.some(f => f.id === id)); - const newFriends = friends.filter(f => !customOrder.includes(f.id)); + const knownIds = customOrder.filter(id => allFriends.some(f => f.id === id)); + const newFriends = allFriends.filter(f => !customOrder.includes(f.id)); return [ - ...knownIds.map(id => friends.find(f => f.id === id)!), + ...knownIds.map(id => allFriends.find(f => f.id === id)!), ...newFriends ]; - }, [friends, customOrder]); + }, [allFriends, customOrder]); const arrowFriends = useMemo((): ArrowFriend[] => { + const calibrated = normalizeAngle(headingDeg - headingOffset); return orderedFriends .filter(f => !hiddenFriendIds.has(f.id)) .map(f => { @@ -76,11 +104,11 @@ export function RoomSessionScreen({ ? getBearingDeg(position, f.position) : null; const arrowDeg = bearingDeg !== null - ? getRelativeBearingDeg(bearingDeg, headingDeg) + ? getRelativeBearingDeg(bearingDeg, calibrated) : null; return { id: f.id, name: f.name || f.id, color: f.color ?? '#888888', arrowDeg }; }); - }, [orderedFriends, hiddenFriendIds, position, headingDeg]); + }, [orderedFriends, hiddenFriendIds, position, headingDeg, headingOffset]); const moveUp = (index: number) => { if (index <= 0) return; @@ -109,6 +137,11 @@ export function RoomSessionScreen({ }); }; + const calibrate = () => { + setHeadingOffset(headingDeg); + void AsyncStorage.setItem('compass:heading_offset', String(headingDeg)); + }; + // Auto-connect Firebase on mount useEffect(() => { let disposed = false; @@ -132,36 +165,30 @@ export function RoomSessionScreen({ }).catch(() => { /* ignore */ }); }, [hiddenStorageKey]); - // Start location watching on mount + // Raw sensor subscriptions for debug display useEffect(() => { - 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) => setSyncError(message) - ); - }; - - void start(); + Magnetometer.setUpdateInterval(200); + Accelerometer.setUpdateInterval(200); + const subMag = Magnetometer.addListener(({ x, y, z }) => setRawMag({ x, y, z })); + const subAccel = Accelerometer.addListener(({ x, y, z }) => setRawAccel({ x, y, z })); + return () => { subMag.remove(); subAccel.remove(); }; + }, []); - return () => { - stopWatching?.(); - setLocationStatus('idle'); - }; + // Load persisted heading calibration offset + useEffect(() => { + AsyncStorage.getItem('compass:heading_offset').then((raw) => { + if (!raw) return; + const parsed = parseFloat(raw); + if (!isNaN(parsed)) setHeadingOffset(parsed); + }).catch(() => { /* ignore */ }); }, []); // Start heading watching on mount useEffect(() => { return watchHeading( (deg) => setHeadingDeg(deg), - (msg) => setSyncError(msg) + (msg) => setSyncError(msg), + (raw) => setRawHeadingDeg(raw) ); }, []); @@ -219,14 +246,14 @@ export function RoomSessionScreen({ }; }, [displayColor, displayName, firebaseSession, parsedRoom, position, roomKey]); - // Absorb newly-seen friends into the custom order + // Absorb newly-seen friends (including virtual) into the custom order useEffect(() => { setCustomOrder(prev => { - const newIds = friends.filter(f => !prev.includes(f.id)).map(f => f.id); + const newIds = allFriends.filter(f => !prev.includes(f.id)).map(f => f.id); if (newIds.length === 0) return prev; return [...prev, ...newIds]; }); - }, [friends]); + }, [allFriends]); return ( <ScrollView contentContainerStyle={styles.container}> @@ -259,6 +286,10 @@ export function RoomSessionScreen({ )} {locationStatus === 'denied' && <Text style={styles.errorText}>Location denied</Text>} {locationStatus === 'watching' && !position && <Text style={styles.valueMuted}>waiting for GPS…</Text>} + <View style={{ flex: 1 }} /> + <Pressable onPress={calibrate} style={({ pressed }) => [styles.calibrateButton, pressed && styles.buttonPressed]}> + <Text style={styles.calibrateButtonText}>Calibrate</Text> + </Pressable> </View> <ArrowsPanel friends={arrowFriends} /> {/* Friend rows with controls */} @@ -272,7 +303,7 @@ export function RoomSessionScreen({ <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]} numberOfLines={1}> {friend.name || friend.id} </Text> - {!hasPos && !isHidden && <Text style={styles.noPos}>no pos</Text>} + {!hasPos && !isHidden && friend.id !== NORTH_FRIEND_ID && <Text style={styles.noPos}>no pos</Text>} <Pressable onPress={() => toggleHidden(friend.id)} style={({ pressed }) => [styles.hideButton, pressed && styles.buttonPressed]} @@ -310,7 +341,9 @@ export function RoomSessionScreen({ {/* ── Invite QR ── */} <View style={styles.card}> <View style={styles.qrWrap}> - <Text style={styles.valueMuted}>Invite others to join this room</Text> + <Pressable onPress={() => void Share.share({ message: `Join me at Friend Compass: ${inviteUrl}` })}> + <Text style={styles.shareLink}>Invite others...</Text> + </Pressable> <QRCode value={inviteUrl} size={200} /> </View> </View> @@ -483,5 +516,26 @@ const styles = StyleSheet.create({ fontSize: 10, color: '#2e2a23', lineHeight: 12 + }, + calibrateButton: { + backgroundColor: '#d7ccb9', + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 4 + }, + calibrateButtonText: { + fontSize: 12, + color: '#5b4a3a', + fontWeight: '600' + }, + shareLink: { + fontSize: 14, + color: '#7a4f2a', + textDecorationLine: 'underline', + fontWeight: '500' + }, + debugText: { + fontSize: 12, + color: '#5b554c' } });