north-star / commit
starting to make the arrow panel
mobile/assets/compass.png | Bin 0 -> 18747 bytes mobile/src/components/arrows_panel.tsx | 166 ++++++++++++++++++++++++ mobile/src/features/friends/sync.ts | 4 +- mobile/src/platform/firebase.ts | 10 +- mobile/src/screens/onboarding_screen.tsx | 36 +++--- mobile/src/screens/room_screen.tsx | 5 + mobile/src/screens/room_session_screen.tsx | 194 ++++++++++++++++++++--------- 7 files changed, 331 insertions(+), 84 deletions(-) diff --git a/mobile/assets/compass.png b/mobile/assets/compass.png new file mode 100644 index 0000000..632ccd1 Binary files /dev/null and b/mobile/assets/compass.png differ diff --git a/mobile/src/components/arrows_panel.tsx b/mobile/src/components/arrows_panel.tsx new file mode 100644 index 0000000..eec655c --- /dev/null +++ b/mobile/src/components/arrows_panel.tsx @@ -0,0 +1,166 @@ +import { StyleSheet, Text, View } from 'react-native'; +import Svg, { G, Path, Circle, Line } from 'react-native-svg'; + +export type ArrowFriend = { + id: string; + name: string; + color: string; + arrowDeg: number | null; + dist: string | null; +}; + +type ArrowsPanelProps = { + friends: ArrowFriend[]; +}; + +// Arrow needle tip pointing up (north), centered at (0,0). +// Drawn in a ±1 unit coordinate space, scaled via SVG transform. +const NEEDLE_TIP = 'M 0 -36 L 8 -10 L 0 -16 L -8 -10 Z'; +const NEEDLE_TAIL = 'M 0 16 L 5 6 L 0 10 L -5 6 Z'; + +const COMPASS_SIZE = 260; +const CENTER = COMPASS_SIZE / 2; + +export function ArrowsPanel({ friends }: ArrowsPanelProps) { + if (friends.length === 0) { + return ( + <View style={styles.emptyWrap}> + <Text style={styles.emptyText}>Waiting for friends to join…</Text> + </View> + ); + } + + const hasNoPosition = friends.every(f => f.arrowDeg === null); + + return ( + <View style={styles.wrap}> + {/* Single shared compass face */} + <View style={styles.compassOuter}> + <Svg width={COMPASS_SIZE} height={COMPASS_SIZE} viewBox={`0 0 ${COMPASS_SIZE} ${COMPASS_SIZE}`}> + {/* Compass ring */} + <Circle + cx={CENTER} + cy={CENTER} + r={CENTER - 4} + fill="rgba(0,0,0,0.04)" + stroke="rgba(0,0,0,0.12)" + strokeWidth={2} + /> + {/* Cardinal tick marks */} + {[0, 90, 180, 270].map(deg => { + const rad = (deg - 90) * (Math.PI / 180); + const inner = CENTER - 20; + const outer = CENTER - 6; + return ( + <Line + key={deg} + x1={CENTER + inner * Math.cos(rad)} + y1={CENTER + inner * Math.sin(rad)} + x2={CENTER + outer * Math.cos(rad)} + y2={CENTER + outer * Math.sin(rad)} + stroke="rgba(0,0,0,0.2)" + strokeWidth={2} + /> + ); + })} + {/* Center dot */} + <Circle cx={CENTER} cy={CENTER} r={6} fill="rgba(0,0,0,0.15)" /> + + {/* One needle per friend */} + {friends.map((friend) => { + if (friend.arrowDeg === null) return null; + return ( + <G key={friend.id} transform={`translate(${CENTER}, ${CENTER}) rotate(${friend.arrowDeg})`}> + {/* Tip */} + <Path d={NEEDLE_TIP} fill={friend.color} opacity={0.92} /> + {/* Tail */} + <Path d={NEEDLE_TAIL} fill={friend.color} opacity={0.5} /> + </G> + ); + })} + + {hasNoPosition && ( + <Circle + cx={CENTER} + cy={CENTER} + r={30} + fill="none" + stroke="rgba(0,0,0,0.15)" + strokeWidth={3} + strokeDasharray="8 5" + /> + )} + </Svg> + </View> + + {/* Legend: one row per friend */} + <View style={styles.legend}> + {friends.map((friend) => ( + <View key={friend.id} style={styles.legendRow}> + <View style={[styles.legendDot, { backgroundColor: friend.color }]} /> + <Text style={styles.legendName} numberOfLines={1}>{friend.name}</Text> + {friend.dist !== null && ( + <Text style={styles.legendDist}>{friend.dist}</Text> + )} + {friend.arrowDeg === null && ( + <Text style={styles.legendNoPos}>no position</Text> + )} + </View> + ))} + </View> + </View> + ); +} + +const styles = StyleSheet.create({ + wrap: { + alignItems: 'center', + gap: 16, + paddingVertical: 4 + }, + compassOuter: { + borderRadius: COMPASS_SIZE / 2, + overflow: 'hidden' + }, + legend: { + alignSelf: 'stretch', + gap: 8, + paddingHorizontal: 4 + }, + legendRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + legendDot: { + width: 12, + height: 12, + borderRadius: 6, + flexShrink: 0 + }, + legendName: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: '#2e2a23' + }, + legendDist: { + fontSize: 13, + color: '#7a7469' + }, + legendNoPos: { + fontSize: 12, + color: '#9a8f84', + fontStyle: 'italic' + }, + emptyWrap: { + paddingHorizontal: 8, + paddingVertical: 12 + }, + emptyText: { + fontSize: 14, + color: '#7a7469', + fontStyle: 'italic' + } +}); + diff --git a/mobile/src/features/friends/sync.ts b/mobile/src/features/friends/sync.ts index d92459a..c27155a 100644 --- a/mobile/src/features/friends/sync.ts +++ b/mobile/src/features/friends/sync.ts @@ -61,12 +61,14 @@ export const subscribeToFriends = ({ db, roomId, roomKey, + selfUid, onUpdate, onError }: { db: Database; roomId: string; roomKey: SymmetricKey | null; + selfUid: string; onUpdate: (friends: MobileFriendRecord[]) => void; onError: (error: Error) => void; }) => { @@ -107,7 +109,7 @@ export const subscribeToFriends = ({ return friend; })); - const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null); + const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null && item.id !== selfUid); nextFriends.sort((a, b) => { const aName = (a.name || '').toLowerCase(); const bName = (b.name || '').toLowerCase(); diff --git a/mobile/src/platform/firebase.ts b/mobile/src/platform/firebase.ts index 27aa473..1a45ebf 100644 --- a/mobile/src/platform/firebase.ts +++ b/mobile/src/platform/firebase.ts @@ -1,5 +1,6 @@ import { getApp, getApps, initializeApp } from 'firebase/app'; -import { getAuth, signInAnonymously } from 'firebase/auth'; +import { initializeAuth, getAuth, getReactNativePersistence, signInAnonymously } from 'firebase/auth'; +import ReactNativeAsyncStorage from '@react-native-async-storage/async-storage'; import { getDatabase, type Database } from 'firebase/database'; const firebaseConfig = { @@ -22,8 +23,11 @@ let cachedSession: FirebaseSession | null = null; export const initFirebaseSession = async () => { if (cachedSession) return cachedSession; - const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig); - const auth = getAuth(app); + const isNew = getApps().length === 0; + const app = isNew ? initializeApp(firebaseConfig) : getApp(); + const auth = isNew + ? initializeAuth(app, { persistence: getReactNativePersistence(ReactNativeAsyncStorage) }) + : getAuth(app); const db = getDatabase(app); const authSignInResponse = await signInAnonymously(auth); diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx index 7c2070b..0fefa6c 100644 --- a/mobile/src/screens/onboarding_screen.tsx +++ b/mobile/src/screens/onboarding_screen.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Image, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; const PRESET_COLORS = [ '#e53935', '#f06292', '#ab47bc', '#7986cb', '#42a5f5', @@ -50,6 +50,8 @@ export function OnboardingScreen({ const hash = buildRoomHash(roomId, keyB64); 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.`); onStartRoom({ roomHash: hash, roomKey: key }); }; @@ -93,8 +95,11 @@ export function OnboardingScreen({ /> <ScrollView contentContainerStyle={styles.container}> <View style={styles.heroCard}> - <View style={styles.heroOrbA} /> - <View style={styles.heroOrbB} /> + <Image + source={require('../../assets/compass.png')} + style={styles.heroCompass} + resizeMode="contain" + /> <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> @@ -180,25 +185,14 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#c9b89d' }, - heroOrbA: { - position: 'absolute', - width: 130, - height: 130, - borderRadius: 65, - backgroundColor: '#c8b08a', - right: -30, - top: -35, - opacity: 0.55 - }, - heroOrbB: { + heroCompass: { position: 'absolute', - width: 100, - height: 100, - borderRadius: 50, - backgroundColor: '#b38a56', - right: 20, - bottom: -45, - opacity: 0.22 + width: 170, + height: 170, + right: -38, + top: -40, + opacity: 0.2, + transform: [{ rotate: '22deg' }] }, heroEyebrow: { letterSpacing: 1.6, diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx index b217828..a2b9021 100644 --- a/mobile/src/screens/room_screen.tsx +++ b/mobile/src/screens/room_screen.tsx @@ -86,6 +86,11 @@ 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 6bb2efd..31a0c9a 100644 --- a/mobile/src/screens/room_session_screen.tsx +++ b/mobile/src/screens/room_session_screen.tsx @@ -1,13 +1,16 @@ import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import QRCode from 'react-native-qrcode-svg'; +import { ArrowsPanel, type ArrowFriend } from '../components/arrows_panel'; import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing'; import { parseRoomFromHash } from '../core/room'; import type { SymmetricKey } from '../core/crypto'; @@ -50,9 +53,11 @@ export function RoomSessionScreen({ const [headingDeg, setHeadingDeg] = useState(0); const [showQr, setShowQr] = useState(false); const [customOrder, setCustomOrder] = useState<string[]>([]); + const [hiddenFriendIds, setHiddenFriendIds] = useState<Set<string>>(new Set()); const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]); const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`; + const hiddenStorageKey = parsedRoom ? `friends:hidden:${parsedRoom.roomId}` : null; const orderedFriends = useMemo(() => { const knownIds = customOrder.filter(id => friends.some(f => f.id === id)); @@ -63,6 +68,23 @@ export function RoomSessionScreen({ ]; }, [friends, customOrder]); + const arrowFriends = useMemo((): ArrowFriend[] => { + return orderedFriends + .filter(f => !hiddenFriendIds.has(f.id)) + .map(f => { + const bearingDeg = position && f.position + ? getBearingDeg(position, f.position) + : null; + const arrowDeg = bearingDeg !== null + ? getRelativeBearingDeg(bearingDeg, headingDeg) + : null; + const dist = position && f.position + ? formatDistance(getDistanceMeters(position, f.position)) + : null; + return { id: f.id, name: f.name || f.id, color: f.color ?? '#888888', arrowDeg, dist }; + }); + }, [orderedFriends, hiddenFriendIds, position, headingDeg]); + const moveUp = (index: number) => { if (index <= 0) return; const ids = orderedFriends.map(f => f.id); @@ -79,6 +101,17 @@ export function RoomSessionScreen({ setCustomOrder(next); }; + const toggleHidden = (id: string) => { + setHiddenFriendIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + if (hiddenStorageKey) { + void AsyncStorage.setItem(hiddenStorageKey, JSON.stringify([...next])); + } + return next; + }); + }; + // Auto-connect Firebase on mount useEffect(() => { let disposed = false; @@ -90,6 +123,18 @@ export function RoomSessionScreen({ return () => { disposed = true; }; }, []); + // Load persisted hidden-friend preferences + useEffect(() => { + if (!hiddenStorageKey) return; + AsyncStorage.getItem(hiddenStorageKey).then((raw) => { + if (!raw) return; + try { + const ids: string[] = JSON.parse(raw); + setHiddenFriendIds(new Set(ids)); + } catch { /* ignore corrupt data */ } + }).catch(() => { /* ignore */ }); + }, [hiddenStorageKey]); + // Start location watching on mount useEffect(() => { let stopWatching: (() => void) | null = null; @@ -161,6 +206,7 @@ export function RoomSessionScreen({ db: firebaseSession.db, roomId, roomKey, + selfUid: firebaseSession.uid, onUpdate: (nextFriends) => { if (!isDisposed) setFriends(nextFriends); }, @@ -188,7 +234,11 @@ export function RoomSessionScreen({ return ( <ScrollView contentContainerStyle={styles.container}> <View style={styles.heroCard}> - <View style={styles.heroOrb} /> + <Image + source={require('../../assets/compass.png')} + style={styles.heroCompass} + resizeMode="contain" + /> <View style={styles.headerRow}> <View> <Text style={styles.title}>Room Session</Text> @@ -200,56 +250,42 @@ export function RoomSessionScreen({ </View> </View> - <View style={styles.card}> - <Pressable - onPress={() => setShowQr((v) => !v)} - style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]} - > - <Text style={styles.label}>Invite others</Text> - <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text> - </Pressable> - {showQr && ( - <View style={styles.qrWrap}> - <QRCode value={inviteUrl} size={200} /> - <Text style={styles.valueMuted}>Scan to join on web or mobile</Text> - </View> - )} + {/* ── Arrows panel – biggest element ── */} + <View style={styles.arrowsCard}> + <View style={styles.arrowsCardHeader}> + <Text style={styles.label}>Bearings</Text> + {!firebaseSession && !syncError && ( + <View style={styles.connectingRowInline}> + <ActivityIndicator size="small" color="#7a4f2a" /> + <Text style={styles.valueMuted}>connecting…</Text> + </View> + )} + {locationStatus === 'denied' && <Text style={styles.errorText}>Location denied</Text>} + {locationStatus === 'watching' && !position && <Text style={styles.valueMuted}>waiting for GPS…</Text>} + </View> + <ArrowsPanel friends={arrowFriends} /> + {!!syncError && <Text style={styles.errorText}>{syncError}</Text>} </View> + {/* ── Friends list ── */} <View style={styles.card}> <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> - )} - {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; - const arrowDeg = bearingDeg !== null - ? getRelativeBearingDeg(bearingDeg, headingDeg) - : null; - const dist = position && friend.position - ? formatDistance(getDistanceMeters(position, friend.position)) - : null; + const isHidden = hiddenFriendIds.has(friend.id); 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 style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888', opacity: isHidden ? 0.35 : 1 }]} /> + <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]}> + {friend.name || friend.id} + </Text> + <Pressable + onPress={() => toggleHidden(friend.id)} + style={({ pressed }) => [styles.hideButton, pressed && styles.buttonPressed]} + > + <Text style={styles.hideButtonText}>{isHidden ? 'show' : 'hide'}</Text> + </Pressable> {orderedFriends.length > 1 && ( <View style={styles.moveButtons}> <Pressable @@ -271,7 +307,26 @@ export function RoomSessionScreen({ </View> ); })} - {!!syncError && <Text style={styles.errorText}>{syncError}</Text>} + {orderedFriends.length === 0 && firebaseSession && ( + <Text style={styles.valueMuted}>No friends in room yet.</Text> + )} + </View> + + {/* ── Invite QR ── */} + <View style={styles.card}> + <Pressable + onPress={() => setShowQr((v) => !v)} + style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]} + > + <Text style={styles.label}>Invite others</Text> + <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text> + </Pressable> + {showQr && ( + <View style={styles.qrWrap}> + <QRCode value={inviteUrl} size={200} /> + <Text style={styles.valueMuted}>Scan to join on web or mobile</Text> + </View> + )} </View> </ScrollView> ); @@ -291,15 +346,14 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#c7b79d' }, - heroOrb: { + heroCompass: { position: 'absolute', - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: '#b38a56', - right: -28, - top: -34, - opacity: 0.25 + width: 160, + height: 160, + right: -32, + top: -36, + opacity: 0.18, + transform: [{ rotate: '18deg' }] }, headerRow: { flexDirection: 'row', @@ -323,10 +377,26 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#d7ccb9' }, - connectingRow: { + arrowsCard: { + backgroundColor: '#e4ddd0', + borderRadius: 16, + paddingVertical: 18, + paddingHorizontal: 12, + gap: 14, + borderWidth: 1, + borderColor: '#cfc4b0', + minHeight: 200 + }, + arrowsCardHeader: { flexDirection: 'row', alignItems: 'center', - gap: 8 + gap: 10, + paddingHorizontal: 4 + }, + connectingRowInline: { + flexDirection: 'row', + alignItems: 'center', + gap: 6 }, qrToggleRow: { flexDirection: 'row', @@ -388,13 +458,19 @@ const styles = StyleSheet.create({ friendName: { flex: 1 }, - friendDist: { - fontSize: 13, - color: '#7a7469' + friendNameHidden: { + opacity: 0.4 }, - friendArrow: { - fontSize: 24, - color: '#2e2a23' + hideButton: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 6, + backgroundColor: '#d7ccb9' + }, + hideButtonText: { + fontSize: 12, + color: '#5b4a3a', + fontWeight: '500' }, moveButtons: { flexDirection: 'column',