north-star / commit
changed how friends are hidden or ordered
mobile/src/platform/heading.ts | 22 +++- mobile/src/screens/room_session_screen.tsx | 185 ++++++++++++++++++----------- 2 files changed, 132 insertions(+), 75 deletions(-) diff --git a/mobile/src/platform/heading.ts b/mobile/src/platform/heading.ts index 858f716..f57288d 100644 --- a/mobile/src/platform/heading.ts +++ b/mobile/src/platform/heading.ts @@ -1,7 +1,9 @@ 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; +// Lower alpha = smoother but slower to track movement. +const SMOOTH_ALPHA = 0.08; +// Ignore heading updates smaller than this to suppress oscillation noise. +const DEAD_ZONE_DEG = 2; /** * Watches device heading using the platform's tilt-compensated compass @@ -20,6 +22,7 @@ export const watchHeading = ( let sSin = 0; let sCos = 1; let initialized = false; + let lastReportedDeg = 0; let subscription: Location.LocationSubscription | null = null; let disposed = false; @@ -31,13 +34,22 @@ export const watchHeading = ( if (!initialized) { sSin = Math.sin(rad); sCos = Math.cos(rad); + lastReportedDeg = rawDeg; initialized = true; - } else { - sSin = SMOOTH_ALPHA * Math.sin(rad) + (1 - SMOOTH_ALPHA) * sSin; - sCos = SMOOTH_ALPHA * Math.cos(rad) + (1 - SMOOTH_ALPHA) * sCos; + onHeading(rawDeg); + return; } + 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; + + // Dead zone: skip tiny updates that are just sensor noise + const diff = Math.abs(((smoothedDeg - lastReportedDeg + 540) % 360) - 180); + if (diff < DEAD_ZONE_DEG) return; + + lastReportedDeg = smoothedDeg; onHeading(smoothedDeg); }).then((sub) => { if (disposed) sub.remove(); diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx index 488c9d8..08739fb 100644 --- a/mobile/src/screens/room_session_screen.tsx +++ b/mobile/src/screens/room_session_screen.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Image, + PanResponder, Pressable, ScrollView, Share, @@ -27,6 +28,8 @@ 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; +// Approximate height of a friend row (pt) used for drag-index calculations +const ROW_HEIGHT = 44; type RoomSessionScreenProps = { roomHash: string; @@ -61,11 +64,16 @@ export function RoomSessionScreen({ 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); + // Drag-to-reorder state + const [activeDragFromIndex, setActiveDragFromIndex] = useState<number | null>(null); + const [activeDragToIndex, setActiveDragToIndex] = useState<number | null>(null); + const [scrollEnabled, setScrollEnabled] = useState(true); + const scrollRef = useRef<ScrollView>(null); + const dragActiveRef = useRef<{ fromIndex: number; toIndex: number } | null>(null); const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]); const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`; @@ -110,21 +118,59 @@ export function RoomSessionScreen({ }); }, [orderedFriends, hiddenFriendIds, position, headingDeg, headingOffset]); - 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); - }; + // Temporarily rearranged list shown while dragging + const displayFriends = useMemo(() => { + if (activeDragFromIndex === null || activeDragToIndex === null) return orderedFriends; + const list = [...orderedFriends]; + const [item] = list.splice(activeDragFromIndex, 1); + list.splice(activeDragToIndex, 0, item); + return list; + }, [orderedFriends, activeDragFromIndex, activeDragToIndex]); - 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); - }; + // One PanResponder per friend, keyed by id, recreated only when order changes + const panResponderMap = useMemo(() => { + const map = new Map<string, ReturnType<typeof PanResponder.create>>(); + orderedFriends.forEach((friend, index) => { + map.set(friend.id, PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onPanResponderGrant: () => { + dragActiveRef.current = { fromIndex: index, toIndex: index }; + setActiveDragFromIndex(index); + setActiveDragToIndex(index); + setScrollEnabled(false); + }, + onPanResponderMove: (_, gs) => { + const toIndex = Math.max(0, Math.min( + orderedFriends.length - 1, + Math.round(index + gs.dy / ROW_HEIGHT) + )); + if (dragActiveRef.current) dragActiveRef.current.toIndex = toIndex; + setActiveDragToIndex(toIndex); + }, + onPanResponderRelease: () => { + setScrollEnabled(true); + const drag = dragActiveRef.current; + dragActiveRef.current = null; + setActiveDragFromIndex(null); + setActiveDragToIndex(null); + if (drag && drag.toIndex !== drag.fromIndex) { + const ids = orderedFriends.map(f => f.id); + ids.splice(drag.fromIndex, 1); + ids.splice(drag.toIndex, 0, orderedFriends[drag.fromIndex].id); + setCustomOrder(ids); + } + }, + onPanResponderTerminate: () => { + setScrollEnabled(true); + dragActiveRef.current = null; + setActiveDragFromIndex(null); + setActiveDragToIndex(null); + }, + })); + }); + return map; + }, [orderedFriends]); const toggleHidden = (id: string) => { setHiddenFriendIds(prev => { @@ -256,7 +302,7 @@ export function RoomSessionScreen({ }, [allFriends]); return ( - <ScrollView contentContainerStyle={styles.container}> + <ScrollView ref={scrollRef} scrollEnabled={scrollEnabled} contentContainerStyle={styles.container}> <View style={styles.heroCard}> <Image source={require('../../assets/compass.png')} @@ -292,42 +338,35 @@ export function RoomSessionScreen({ </Pressable> </View> <ArrowsPanel friends={arrowFriends} /> - {/* Friend rows with controls */} + {/* Friend rows with drag-to-reorder and checkbox visibility */} <View style={styles.friendsList}> - {orderedFriends.map((friend, index) => { + {displayFriends.map((friend) => { const isHidden = hiddenFriendIds.has(friend.id); const hasPos = !!friend.position; + const isDragging = activeDragFromIndex !== null && + orderedFriends[activeDragFromIndex]?.id === friend.id; + const pr = panResponderMap.get(friend.id); return ( - <View key={friend.id} style={styles.friendRow}> + <View key={friend.id} style={[styles.friendRow, isDragging && styles.friendRowDragging]}> + {/* Drag handle */} + <View {...pr?.panHandlers} style={styles.dragHandle}> + <View style={styles.hamburgerLine} /> + <View style={styles.hamburgerLine} /> + <View style={styles.hamburgerLine} /> + </View> <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888', opacity: isHidden ? 0.35 : 1 }]} /> <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]} numberOfLines={1}> {friend.name || friend.id} </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]} - > - <Text style={styles.hideButtonText}>{isHidden ? 'show' : 'hide'}</Text> - </Pressable> - {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> + {!hasPos && !isHidden && friend.id !== NORTH_FRIEND_ID && ( + <Text style={styles.noPos}>no pos</Text> )} + {/* Checkbox: checked = visible */} + <Pressable onPress={() => toggleHidden(friend.id)} hitSlop={8}> + <View style={styles.checkbox}> + {!isHidden && <View style={styles.checkboxTick} />} + </View> + </Pressable> </View> ); })} @@ -488,34 +527,40 @@ const styles = StyleSheet.create({ color: '#9a8f84', fontStyle: 'italic' }, - hideButton: { - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 6, - backgroundColor: '#d7ccb9' - }, - hideButtonText: { - fontSize: 12, - color: '#5b4a3a', - fontWeight: '500' - }, - moveButtons: { - flexDirection: 'column', - gap: 2 - }, - moveButton: { + // Drag handle (hamburger) + dragHandle: { paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 4, - backgroundColor: '#d7ccb9' - }, - moveButtonDim: { - opacity: 0.3 + paddingVertical: 8, + gap: 3, + justifyContent: 'center' as const, + alignItems: 'center' as const + }, + hamburgerLine: { + width: 16, + height: 2, + backgroundColor: '#9a8f84', + borderRadius: 1 + }, + friendRowDragging: { + opacity: 0.5, + backgroundColor: '#d7ccb9', + borderRadius: 8 }, - moveButtonText: { - fontSize: 10, - color: '#2e2a23', - lineHeight: 12 + // Checkbox + checkbox: { + width: 20, + height: 20, + borderRadius: 4, + borderWidth: 2, + borderColor: '#7a4f2a', + justifyContent: 'center' as const, + alignItems: 'center' as const + }, + checkboxTick: { + width: 10, + height: 10, + borderRadius: 2, + backgroundColor: '#7a4f2a' }, calibrateButton: { backgroundColor: '#d7ccb9',