diff --git a/app/.gitignore b/app/.gitignore index 6bf33056..21486565 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -34,4 +34,5 @@ yarn-error.* # typescript *.tsbuildinfo -android/* \ No newline at end of file +android/* +ios/* diff --git a/app/App.tsx b/app/App.tsx deleted file mode 100644 index 60e44938..00000000 --- a/app/App.tsx +++ /dev/null @@ -1,3 +0,0 @@ -// App.tsx -import App from './app/index'; // your actual entry file -export default App; \ No newline at end of file diff --git a/app/app.json b/app/app.json index 9acdac77..1343e021 100644 --- a/app/app.json +++ b/app/app.json @@ -2,10 +2,10 @@ "expo": { "name": "friend-lite-app", "slug": "friend-lite-app", + "scheme": "friendlite", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "entryPoint": "./app/index.tsx", "userInterfaceStyle": "light", "splash": { "image": "./assets/splash.png", @@ -17,7 +17,7 @@ ], "ios": { "supportsTablet": true, - "bundleIdentifier": "com.cupbearer5517.friendlite", + "bundleIdentifier": "com.sigmoid.friendlite", "infoPlist": { "NSMicrophoneUsageDescription": "Friend-Lite needs access to your microphone to stream audio to the backend for processing." } @@ -27,7 +27,7 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "package": "com.cupbearer5517.friendlite", + "package": "com.sigmoid.friendlite", "permissions": [ "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", @@ -49,7 +49,9 @@ "enableNotifications": true, "enableBackgroundAudio": true, "enableDeviceDetection": true, - "iosBackgroundModes": { "useProcessing": true }, + "iosBackgroundModes": { + "useProcessing": true + }, "iosConfig": { "microphoneUsageDescription": "We use the mic for live audio streaming" } @@ -91,7 +93,8 @@ ] } } - ] + ], + "expo-router" ], "extra": { "eas": { @@ -99,4 +102,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index d2a8b0bc..be5a2eb9 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -1,5 +1,17 @@ import { Stack } from "expo-router"; +import { useEffect } from "react"; export default function RootLayout() { - return ; + useEffect(() => { + console.log('[RootLayout] Navigation container initialized'); + }, []); + + return ( + + ); } diff --git a/app/app/index.tsx b/app/app/index.tsx index 8bb1234a..db5dfff5 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -2,11 +2,12 @@ import React, { useRef, useCallback, useEffect, useState } from 'react'; import { StyleSheet, Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, Button, TouchableOpacity, KeyboardAvoidingView } from 'react-native'; import { OmiConnection } from 'friend-lite-react-native'; // OmiDevice also comes from here import { State as BluetoothState } from 'react-native-ble-plx'; // Import State from ble-plx +import { Link } from 'expo-router'; // Hooks -import { useBluetoothManager } from './hooks/useBluetoothManager'; -import { useDeviceScanning } from './hooks/useDeviceScanning'; -import { useDeviceConnection } from './hooks/useDeviceConnection'; +import { useBluetoothManager } from '../src/hooks/useBluetoothManager'; +import { useDeviceScanning } from '../src/hooks/useDeviceScanning'; +import { useDeviceConnection } from '../src/hooks/useDeviceConnection'; import { saveLastConnectedDeviceId, getLastConnectedDeviceId, @@ -16,24 +17,26 @@ import { getUserId, getAuthEmail, getJwtToken, -} from './utils/storage'; -import { useAudioListener } from './hooks/useAudioListener'; -import { useAudioStreamer } from './hooks/useAudioStreamer'; -import { usePhoneAudioRecorder } from './hooks/usePhoneAudioRecorder'; +} from '../src/utils/storage'; +import { useAudioListener } from '../src/hooks/useAudioListener'; +import { useAudioStreamer } from '../src/hooks/useAudioStreamer'; +import { usePhoneAudioRecorder } from '../src/hooks/usePhoneAudioRecorder'; // Components -import BluetoothStatusBanner from './components/BluetoothStatusBanner'; -import ScanControls from './components/ScanControls'; -import DeviceListItem from './components/DeviceListItem'; -import DeviceDetails from './components/DeviceDetails'; -import AuthSection from './components/AuthSection'; -import BackendStatus from './components/BackendStatus'; -import PhoneAudioButton from './components/PhoneAudioButton'; +import BluetoothStatusBanner from '../src/components/BluetoothStatusBanner'; +import ScanControls from '../src/components/ScanControls'; +import DeviceListItem from '../src/components/DeviceListItem'; +import DeviceDetails from '../src/components/DeviceDetails'; +import AuthSection from '../src/components/AuthSection'; +import BackendStatus from '../src/components/BackendStatus'; +import PhoneAudioButton from '../src/components/PhoneAudioButton'; export default function App() { // Initialize OmiConnection const omiConnection = useRef(new OmiConnection()).current; + // Removed router navigation - using Link component instead + // Filter state const [showOnlyOmi, setShowOnlyOmi] = useState(false); @@ -517,11 +520,18 @@ export default function App() { behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0} > - - Friend Lite + + Friend Lite + + + βš™οΈ + + + {/* Backend Connection - moved to top */} { + try { + console.log('[StatsScreen] Attempting to go back'); + if (routerHook && typeof routerHook.back === 'function') { + routerHook.back(); + } else if (navigationRouter && typeof navigationRouter.back === 'function') { + navigationRouter.back(); + } else { + console.error('[StatsScreen] No valid router for back navigation'); + } + } catch (error) { + console.error('[StatsScreen] Navigation back error:', error); + } + }; + const [logs, setLogs] = useState([]); + const [stats, setStats] = useState(null); + const [currentSession, setCurrentSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const loadData = async () => { + try { + setIsLoading(true); + const [logsData, statsData, sessionData] = await Promise.all([ + getBluetoothLogs(), + getBluetoothStats(), + getCurrentSession(), + ]); + + setLogs(logsData.slice(0, 20)); // Show last 20 events + setStats(statsData); + setCurrentSession(sessionData); + } catch (error) { + console.error('[StatsScreen] Error loading data:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleClearLogs = () => { + Alert.alert( + 'Clear All Logs', + 'Are you sure you want to clear all Bluetooth connection logs and statistics? This cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + await clearBluetoothLogs(); + await loadData(); + }, + }, + ] + ); + }; + + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + }; + + const formatTimestamp = (timestamp: number): string => { + const date = new Date(timestamp); + return date.toLocaleTimeString(); + }; + + const getEventIcon = (event: BluetoothEvent): string => { + switch (event) { + case BluetoothEvent.CONNECT_SUCCESS: + case BluetoothEvent.AUTO_RECONNECT_SUCCESS: + return 'βœ…'; + case BluetoothEvent.CONNECT_FAILURE: + case BluetoothEvent.AUTO_RECONNECT_FAILURE: + return '❌'; + case BluetoothEvent.DISCONNECT: + return 'πŸ”Œ'; + case BluetoothEvent.CONNECT_ATTEMPT: + case BluetoothEvent.AUTO_RECONNECT_ATTEMPT: + return 'πŸ”„'; + case BluetoothEvent.ERROR: + return '⚠️'; + case BluetoothEvent.STATE_CHANGE: + return 'πŸ”€'; + case BluetoothEvent.SCAN_START: + return 'πŸ”'; + case BluetoothEvent.SCAN_STOP: + return '⏸️'; + default: + return 'πŸ“'; + } + }; + + const getEventLabel = (event: BluetoothEvent): string => { + switch (event) { + case BluetoothEvent.CONNECT_ATTEMPT: + return 'Connect Attempt'; + case BluetoothEvent.CONNECT_SUCCESS: + return 'Connected'; + case BluetoothEvent.CONNECT_FAILURE: + return 'Connection Failed'; + case BluetoothEvent.DISCONNECT: + return 'Disconnected'; + case BluetoothEvent.AUTO_RECONNECT_ATTEMPT: + return 'Auto Reconnect Attempt'; + case BluetoothEvent.AUTO_RECONNECT_SUCCESS: + return 'Auto Reconnected'; + case BluetoothEvent.AUTO_RECONNECT_FAILURE: + return 'Auto Reconnect Failed'; + case BluetoothEvent.STATE_CHANGE: + return 'State Change'; + case BluetoothEvent.ERROR: + return 'Error'; + case BluetoothEvent.SCAN_START: + return 'Scan Started'; + case BluetoothEvent.SCAN_STOP: + return 'Scan Stopped'; + default: + return event; + } + }; + + if (isLoading) { + return ( + + + + Loading statistics... + + + ); + } + + return ( + + + + ← Back + + Connection Stats + + πŸ—‘οΈ + + + + + {/* Bluetooth Summary Section */} + + πŸ“Š Bluetooth Summary + + + {stats?.totalSessions || 0} + Total Sessions + + + + {stats && stats.totalConnections > 0 + ? `${((stats.successfulConnections / stats.totalConnections) * 100).toFixed(1)}%` + : '0%'} + + Success Rate + + + + {stats?.averageSessionDuration ? formatDuration(stats.averageSessionDuration) : '0s'} + + Avg Duration + + + {stats?.totalDisconnects || 0} + Disconnects + + + + + {/* Current Connection Section */} + {currentSession && ( + + πŸ”· Current Connection + + + Status: + Connected βœ… + + + Device: + {currentSession.deviceId} + + {currentSession.deviceName && ( + + Name: + {currentSession.deviceName} + + )} + + Duration: + + {formatDuration(Date.now() - currentSession.startTime)} + + + + Started: + {formatTimestamp(currentSession.startTime)} + + + + )} + + {/* Recent Events Section */} + + πŸ“‹ Recent Events (Last 24h) + {logs.length > 0 ? ( + + {logs.map((log) => ( + + + {formatTimestamp(log.timestamp)} + {getEventIcon(log.event)} + + {getEventLabel(log.event)} + {log.details.deviceId && ( + Device: {log.details.deviceId} + )} + {log.details.deviceName && ( + Name: {log.details.deviceName} + )} + {log.details.state && ( + State: {log.details.state} + )} + {log.details.duration && ( + Duration: {formatDuration(log.details.duration)} + )} + {log.details.errorMessage && ( + + Error: {log.details.errorMessage} + + )} + + ))} + + ) : ( + + No events recorded yet + + Connect to a Bluetooth device to start logging + + + )} + + + {/* WebSocket Stub Section */} + + 🌐 WebSocket Connection + + 🚧 + Logging Not Implemented + + WebSocket connection logging will be added in Phase 2. It will track connection + state, reconnection attempts, and streaming metrics. + + + + + {/* Phone Audio Stub Section */} + + 🎀 Phone Audio + + 🚧 + Logging Not Implemented + + Phone audio recording logging will be added in Phase 2. It will track recording + sessions, duration, and audio streaming metrics. + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 15, + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + backButton: { + padding: 5, + }, + backButtonText: { + fontSize: 16, + color: '#007AFF', + fontWeight: '600', + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + }, + clearButton: { + padding: 5, + }, + clearButtonText: { + fontSize: 20, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 15, + paddingBottom: 30, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 10, + fontSize: 16, + color: '#666', + }, + section: { + marginBottom: 20, + padding: 15, + backgroundColor: 'white', + borderRadius: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginBottom: 15, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + statItem: { + width: '48%', + padding: 15, + backgroundColor: '#f8f9fa', + borderRadius: 8, + marginBottom: 10, + alignItems: 'center', + }, + statValue: { + fontSize: 24, + fontWeight: 'bold', + color: '#007AFF', + marginBottom: 5, + }, + statLabel: { + fontSize: 12, + color: '#666', + textAlign: 'center', + }, + currentConnection: { + backgroundColor: '#f8f9fa', + padding: 15, + borderRadius: 8, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 6, + }, + statusLabel: { + fontSize: 14, + color: '#666', + fontWeight: '500', + }, + statusValue: { + fontSize: 14, + color: '#333', + fontWeight: '600', + }, + statusActive: { + color: '#4CD964', + }, + eventsList: { + marginTop: 5, + }, + eventItem: { + backgroundColor: '#f8f9fa', + padding: 12, + borderRadius: 8, + marginBottom: 10, + borderLeftWidth: 3, + borderLeftColor: '#007AFF', + }, + eventHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 5, + }, + eventTime: { + fontSize: 12, + color: '#666', + }, + eventIcon: { + fontSize: 16, + }, + eventType: { + fontSize: 15, + fontWeight: '600', + color: '#333', + marginBottom: 5, + }, + eventDetail: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, + errorText: { + color: '#FF3B30', + }, + emptyState: { + padding: 30, + alignItems: 'center', + }, + emptyStateText: { + fontSize: 16, + color: '#666', + fontWeight: '500', + marginBottom: 5, + }, + emptyStateSubtext: { + fontSize: 14, + color: '#999', + textAlign: 'center', + }, + stubCard: { + backgroundColor: '#FFF9E6', + padding: 20, + borderRadius: 8, + alignItems: 'center', + borderWidth: 1, + borderColor: '#FFE066', + }, + stubIcon: { + fontSize: 40, + marginBottom: 10, + }, + stubTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 8, + }, + stubDescription: { + fontSize: 13, + color: '#666', + textAlign: 'center', + lineHeight: 18, + }, +}); diff --git a/app/install.sh b/app/install.sh new file mode 100755 index 00000000..0f0587b0 --- /dev/null +++ b/app/install.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Friend-Lite iOS App Installer +# Simple script to build and run the iOS app on simulators or physical devices +# Usage: ./install.sh + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Print colored output +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_header() { + echo -e "${CYAN}================================${NC}" + echo -e "${CYAN}$1${NC}" + echo -e "${CYAN}================================${NC}" +} + +# Check if we're in the app directory +if [ ! -f "package.json" ] || [ ! -f "app.json" ]; then + print_error "Please run this script from the app directory (./app/)" + exit 1 +fi + +print_header "Friend-Lite iOS App Installer" + +# Check for required tools +print_info "Checking dependencies..." + +if ! command -v node &> /dev/null; then + print_error "Node.js is not installed. Please install it from https://nodejs.org/" + exit 1 +fi + +if ! command -v npm &> /dev/null; then + print_error "npm is not installed. Please install Node.js from https://nodejs.org/" + exit 1 +fi + +if ! command -v xcrun &> /dev/null; then + print_error "Xcode command line tools not found. Please install Xcode." + exit 1 +fi + +print_success "All required tools are installed" + +# Install npm dependencies if node_modules doesn't exist +if [ ! -d "node_modules" ]; then + print_info "Installing npm dependencies..." + npm install + print_success "Dependencies installed" +else + print_info "Dependencies already installed (skipping npm install)" + echo -e " ${YELLOW}Tip:${NC} Run 'npm install' manually if you need to update dependencies" +fi + +# Check if iOS build exists +if [ ! -d "ios" ]; then + print_warning "iOS native project not found. Will be generated on first build." +fi + +# Get available iOS simulators +print_info "Fetching available iOS devices..." +DEVICES=$(xcrun simctl list devices available | grep -E "iPhone|iPad" | grep -v "unavailable" || true) + +if [ -z "$DEVICES" ]; then + print_warning "No iOS simulators found. Will build for connected device or default simulator." + DEVICE_OPTION="" +else + print_info "Available iOS Simulators:" + echo "$DEVICES" | nl -w2 -s') ' + echo "" + echo "Options:" + echo " 1) Build for specific simulator (enter number above)" + echo " 2) Build for connected physical device" + echo " 3) Build for default simulator" + echo "" + read -p "Select option (1-3) or press Enter for default simulator: " DEVICE_CHOICE + + if [ "$DEVICE_CHOICE" = "1" ]; then + read -p "Enter simulator number: " SIM_NUM + DEVICE_NAME=$(echo "$DEVICES" | sed -n "${SIM_NUM}p" | sed -E 's/.*\(([-A-Z0-9]+)\).*/\1/' | xargs) + if [ -n "$DEVICE_NAME" ]; then + DEVICE_OPTION="--device $DEVICE_NAME" + print_success "Selected device: $DEVICE_NAME" + else + print_warning "Invalid selection, using default simulator" + DEVICE_OPTION="" + fi + elif [ "$DEVICE_CHOICE" = "2" ]; then + DEVICE_OPTION="--device" + print_success "Will build for connected physical device" + else + DEVICE_OPTION="" + print_success "Will use default simulator" + fi +fi + +# Ask about tunnel mode +echo "" +print_info "Connection Mode:" +echo " Tunnel mode allows connection from physical devices outside your local network" +echo " Local mode is faster for development on simulators" +echo "" +read -p "Use tunnel mode? (y/N): " USE_TUNNEL + +TUNNEL_FLAG="" +if [[ "$USE_TUNNEL" =~ ^[Yy]$ ]]; then + TUNNEL_FLAG="--tunnel" + print_success "Tunnel mode enabled" +else + print_success "Using local network mode" +fi + +# Build and run +print_header "Building and Running iOS App" + +COMMAND="npx expo run:ios $DEVICE_OPTION $TUNNEL_FLAG" +print_info "Executing: $COMMAND" +echo "" + +# Run the command +eval $COMMAND + +print_success "iOS app build and launch completed!" +print_info "The app should now be running on your selected device" diff --git a/app/package-lock.json b/app/package-lock.json index be20753f..9c432c34 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -17,6 +17,7 @@ "expo": "~53.0.9", "expo-build-properties": "~0.14.8", "expo-dev-client": "~5.2.4", + "expo-router": "~5.1.7", "expo-status-bar": "~2.2.3", "friend-lite-react-native": "^1.0.2", "install": "^0.13.0", @@ -25,11 +26,14 @@ "react-native": "0.79.6", "react-native-base64": "^0.2.1", "react-native-ble-plx": "^3.5.0", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.18.0", "setimmediate": "^1.0.5", "webidl-conversions": "^7.0.0" }, "devDependencies": { "@babel/core": "^7.20.0", + "@expo/ngrok": "^4.1.3", "@react-native-community/cli": "latest", "@types/react": "~19.0.10", "typescript": "~5.8.3" @@ -2017,6 +2021,217 @@ "resolve-from": "^5.0.0" } }, + "node_modules/@expo/metro-runtime": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-5.0.5.tgz", + "integrity": "sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/@expo/ngrok": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@expo/ngrok/-/ngrok-4.1.3.tgz", + "integrity": "sha512-AESYaROGIGKWwWmUyQoUXcbvaUZjmpecC5buArXxYou+RID813F8T0Y5jQ2HUY49mZpYfJiy9oh4VSN37GgrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@expo/ngrok-bin": "2.3.42", + "got": "^11.5.1", + "uuid": "^3.3.2", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/@expo/ngrok-bin": { + "version": "2.3.42", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz", + "integrity": "sha512-kyhORGwv9XpbPeNIrX6QZ9wDVCDOScyTwxeS+ScNmUqYoZqD9LRmEqF7bpDh5VonTsrXgWrGl7wD2++oSHcaTQ==", + "dev": true, + "bin": { + "ngrok": "bin/ngrok.js" + }, + "optionalDependencies": { + "@expo/ngrok-bin-darwin-arm64": "2.3.41", + "@expo/ngrok-bin-darwin-x64": "2.3.41", + "@expo/ngrok-bin-freebsd-ia32": "2.3.41", + "@expo/ngrok-bin-freebsd-x64": "2.3.41", + "@expo/ngrok-bin-linux-arm": "2.3.41", + "@expo/ngrok-bin-linux-arm64": "2.3.41", + "@expo/ngrok-bin-linux-ia32": "2.3.41", + "@expo/ngrok-bin-linux-x64": "2.3.41", + "@expo/ngrok-bin-sunos-x64": "2.3.41", + "@expo/ngrok-bin-win32-ia32": "2.3.41", + "@expo/ngrok-bin-win32-x64": "2.3.41" + } + }, + "node_modules/@expo/ngrok-bin-darwin-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz", + "integrity": "sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-darwin-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz", + "integrity": "sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz", + "integrity": "sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz", + "integrity": "sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz", + "integrity": "sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz", + "integrity": "sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz", + "integrity": "sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz", + "integrity": "sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-sunos-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz", + "integrity": "sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/@expo/ngrok-bin-win32-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz", + "integrity": "sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok-bin-win32-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz", + "integrity": "sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@expo/ngrok/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/@expo/osascript": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.2.5.tgz", @@ -2284,6 +2499,18 @@ "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", "license": "MIT" }, + "node_modules/@expo/server": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@expo/server/-/server-0.6.3.tgz", + "integrity": "sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "debug": "^4.3.4", + "source-map-support": "~0.5.21", + "undici": "^6.18.2 || ^7.0.0" + } + }, "node_modules/@expo/spawn-async": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", @@ -2737,6 +2964,39 @@ "node": ">=14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", @@ -3299,6 +3559,117 @@ } } }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.5.tgz", + "integrity": "sha512-Zm9UOTfEtBLL7Wm+JBc0v/lh72cen9a8WVN5KSCEN7EtiQIPXbQUZg1ktEzme600HhxvaNZzzSz0X+w2E5nG5w==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.8.2", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.20", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.13.1.tgz", + "integrity": "sha512-aPf1vjQhMytPC9CmJu28hT5eTaBJuqIf9T6IRICtap5HHgFLrsYizLZrg3D0H2AoPyOoijMPWzwf7VCBzfGvrg==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.1", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/core/node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT" + }, + "node_modules/@react-navigation/elements": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.8.2.tgz", + "integrity": "sha512-K5NWIMar81oAoRAgLwrWcLpXzY2K5yG3gNU/56uyC12u+i5SyIVAv+ygP36UXvrNLzDigg8OdRSdEBb8ePqQtA==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.1.20", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.1.20", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.20.tgz", + "integrity": "sha512-15luFq+35M2IOMHgbTJ0XDkPY7gm3YlR3yQKTuOTOHs+EeAUX71DlUuqcWMRqB0tt+OT6HimDQR7OboTB0N30g==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.13.1", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.6.3.tgz", + "integrity": "sha512-F0f0+3K1mVWiQEZbyZen0LAl7Gc4qpbWM4Tpva5aCqBAECZyn7/uLbVhSXtC/EwzMqQ+ojPLtceFQhXhJqfqfg==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.8.2", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.20", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.1.tgz", + "integrity": "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3329,6 +3700,19 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -3359,6 +3743,19 @@ "react-native": "*" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3400,6 +3797,19 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3409,6 +3819,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3433,6 +3850,16 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", @@ -3452,6 +3879,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4187,6 +4624,51 @@ "node": ">= 0.8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4387,6 +4869,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4451,6 +4939,32 @@ "node": ">=0.8" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4469,6 +4983,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -4694,6 +5218,44 @@ "node": ">=0.10.0" } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4724,6 +5286,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -4853,6 +5425,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -5199,52 +5781,194 @@ "expo": "*" } }, - "node_modules/expo-dev-menu-interface": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz", - "integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==", + "node_modules/expo-dev-menu-interface": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz", + "integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "18.1.11", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.11.tgz", + "integrity": "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.2.tgz", + "integrity": "sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, + "node_modules/expo-keep-awake": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", + "integrity": "sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-linking": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz", + "integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "expo-constants": "~18.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-linking/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/expo-linking/node_modules/@expo/config": { + "version": "12.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.10.tgz", + "integrity": "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.2", + "@expo/config-types": "^54.0.8", + "@expo/json-file": "^10.0.7", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^10.4.2", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "3.35.0" + } + }, + "node_modules/expo-linking/node_modules/@expo/config-plugins": { + "version": "54.0.2", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.2.tgz", + "integrity": "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/config-types": "^54.0.8", + "@expo/json-file": "~10.0.7", + "@expo/plist": "^0.4.7", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^10.4.2", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-linking/node_modules/@expo/config-types": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.8.tgz", + "integrity": "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==", + "license": "MIT", + "peer": true + }, + "node_modules/expo-linking/node_modules/@expo/env": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.7.tgz", + "integrity": "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg==", + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/expo-linking/node_modules/@expo/json-file": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.7.tgz", + "integrity": "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==", "license": "MIT", - "peerDependencies": { - "expo": "*" + "peer": true, + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" } }, - "node_modules/expo-file-system": { - "version": "18.1.11", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.11.tgz", - "integrity": "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==", + "node_modules/expo-linking/node_modules/@expo/plist": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.7.tgz", + "integrity": "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" + "peer": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" } }, - "node_modules/expo-font": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.2.tgz", - "integrity": "sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A==", + "node_modules/expo-linking/node_modules/expo-constants": { + "version": "18.0.10", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", + "integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==", "license": "MIT", + "peer": true, "dependencies": { - "fontfaceobserver": "^2.1.0" + "@expo/config": "~12.0.10", + "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", - "react": "*" + "react-native": "*" } }, - "node_modules/expo-json-utils": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", - "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", - "license": "MIT" - }, - "node_modules/expo-keep-awake": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", - "integrity": "sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" + "node_modules/expo-linking/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/expo-manifests": { @@ -5297,6 +6021,60 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-router": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.7.tgz", + "integrity": "sha512-E7hIqTZs4Cub4sbYPeednfYPi+2cyRGMdqc5IYBJ/vC+WBKoYJ8C9eU13ZLbPz//ZybSo2Dsm7v89uFIlO2Gow==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "5.0.5", + "@expo/schema-utils": "^0.1.0", + "@expo/server": "^0.6.3", + "@radix-ui/react-slot": "1.2.0", + "@react-navigation/bottom-tabs": "^7.3.10", + "@react-navigation/native": "^7.1.6", + "@react-navigation/native-stack": "^7.3.10", + "client-only": "^0.0.1", + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.1.6", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "@react-navigation/drawer": "^7.3.9", + "expo": "*", + "expo-constants": "*", + "expo-linking": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": "*", + "react-native-screens": "*" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/jest-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-status-bar": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.2.3.tgz", @@ -5430,6 +6208,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -5745,6 +6532,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5818,6 +6631,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5843,6 +6663,20 @@ "node": ">= 0.8" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6428,6 +7262,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -6469,6 +7310,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7009,6 +7860,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7563,6 +8424,16 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -7724,6 +8595,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -7910,6 +8794,16 @@ "node": ">=8" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8236,6 +9130,17 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8269,6 +9174,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -8299,6 +9222,19 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8379,6 +9315,24 @@ } } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8483,6 +9437,30 @@ "react-native": "*" } }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz", + "integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/@react-native/normalize-colors": { "version": "0.79.6", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.6.tgz", @@ -8738,6 +9716,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -8762,6 +9747,19 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -9089,6 +10087,12 @@ "node": ">= 0.8" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -9108,6 +10112,21 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sf-symbols-typescript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.1.0.tgz", + "integrity": "sha512-ezT7gu/SHTPIOEEoG6TF+O0m5eewl0ZDAO4AtdBi5HjsrUI6JdCG17+Q8+aKp0heM06wZKApRCn5olNbs0Wb/A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9246,6 +10265,21 @@ "node": ">= 5.10.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -9352,6 +10386,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9415,6 +10458,15 @@ "node": ">= 0.10.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -10008,6 +11060,24 @@ "punycode": "^2.1.0" } }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10066,6 +11136,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", diff --git a/app/package.json b/app/package.json index 91ab6690..9a3e3b64 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "friend-lite-app", "version": "1.0.0", - "main": "node_modules/expo/AppEntry.js", + "main": "expo-router/entry", "scripts": { "start": "expo start", "android": "expo run:android", @@ -18,6 +18,7 @@ "expo": "~53.0.9", "expo-build-properties": "~0.14.8", "expo-dev-client": "~5.2.4", + "expo-router": "~5.1.7", "expo-status-bar": "~2.2.3", "friend-lite-react-native": "^1.0.2", "install": "^0.13.0", @@ -26,11 +27,14 @@ "react-native": "0.79.6", "react-native-base64": "^0.2.1", "react-native-ble-plx": "^3.5.0", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.18.0", "setimmediate": "^1.0.5", "webidl-conversions": "^7.0.0" }, "devDependencies": { "@babel/core": "^7.20.0", + "@expo/ngrok": "^4.1.3", "@react-native-community/cli": "latest", "@types/react": "~19.0.10", "typescript": "~5.8.3" diff --git a/app/app/components/AuthSection.tsx b/app/src/components/AuthSection.tsx similarity index 100% rename from app/app/components/AuthSection.tsx rename to app/src/components/AuthSection.tsx diff --git a/app/app/components/BackendStatus.tsx b/app/src/components/BackendStatus.tsx similarity index 100% rename from app/app/components/BackendStatus.tsx rename to app/src/components/BackendStatus.tsx diff --git a/app/app/components/BluetoothStatusBanner.tsx b/app/src/components/BluetoothStatusBanner.tsx similarity index 100% rename from app/app/components/BluetoothStatusBanner.tsx rename to app/src/components/BluetoothStatusBanner.tsx diff --git a/app/app/components/DeviceDetails.tsx b/app/src/components/DeviceDetails.tsx similarity index 100% rename from app/app/components/DeviceDetails.tsx rename to app/src/components/DeviceDetails.tsx diff --git a/app/app/components/DeviceListItem.tsx b/app/src/components/DeviceListItem.tsx similarity index 100% rename from app/app/components/DeviceListItem.tsx rename to app/src/components/DeviceListItem.tsx diff --git a/app/app/components/PhoneAudioButton.tsx b/app/src/components/PhoneAudioButton.tsx similarity index 100% rename from app/app/components/PhoneAudioButton.tsx rename to app/src/components/PhoneAudioButton.tsx diff --git a/app/app/components/ScanControls.tsx b/app/src/components/ScanControls.tsx similarity index 100% rename from app/app/components/ScanControls.tsx rename to app/src/components/ScanControls.tsx diff --git a/app/app/components/StatusIndicator.tsx b/app/src/components/StatusIndicator.tsx similarity index 100% rename from app/app/components/StatusIndicator.tsx rename to app/src/components/StatusIndicator.tsx diff --git a/app/app/hooks/.gitkeep b/app/src/hooks/.gitkeep similarity index 100% rename from app/app/hooks/.gitkeep rename to app/src/hooks/.gitkeep diff --git a/app/app/hooks/useAudioListener.ts b/app/src/hooks/useAudioListener.ts similarity index 100% rename from app/app/hooks/useAudioListener.ts rename to app/src/hooks/useAudioListener.ts diff --git a/app/app/hooks/useAudioStreamer.ts b/app/src/hooks/useAudioStreamer.ts similarity index 100% rename from app/app/hooks/useAudioStreamer.ts rename to app/src/hooks/useAudioStreamer.ts diff --git a/app/src/hooks/useBluetoothLogger.ts b/app/src/hooks/useBluetoothLogger.ts new file mode 100644 index 00000000..4ef4c48e --- /dev/null +++ b/app/src/hooks/useBluetoothLogger.ts @@ -0,0 +1,194 @@ +import { useCallback, useRef } from 'react'; +import { + saveBluetoothLog, + startSession, + endCurrentSession, + updateSession, + generateId, + truncateDeviceId, + BluetoothEvent, + BluetoothLogEntry, +} from '../utils/bluetoothLogger'; + +interface UseBluetoothLogger { + logScanStart: () => Promise; + logScanStop: () => Promise; + logDeviceFound: (deviceId: string, deviceName?: string) => Promise; + logConnectionAttempt: (deviceId: string, deviceName?: string, isAutoReconnect?: boolean) => Promise; + logConnectionSuccess: (deviceId: string, deviceName?: string, sessionId?: string) => Promise; + logConnectionFailure: (deviceId: string, errorMessage: string, sessionId?: string) => Promise; + logDisconnect: (deviceId: string, deviceName?: string) => Promise; + logStateChange: (deviceId: string, state: string) => Promise; + logError: (deviceId: string, errorMessage: string) => Promise; +} + +export const useBluetoothLogger = (): UseBluetoothLogger => { + const currentSessionIdRef = useRef(null); + + const logScanStart = useCallback(async (): Promise => { + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.SCAN_START, + details: {}, + }; + await saveBluetoothLog(entry); + }, []); + + const logScanStop = useCallback(async (): Promise => { + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.SCAN_STOP, + details: {}, + }; + await saveBluetoothLog(entry); + }, []); + + const logDeviceFound = useCallback(async (deviceId: string, deviceName?: string): Promise => { + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.DEVICE_FOUND, + details: { + deviceId: truncateDeviceId(deviceId), + deviceName, + }, + }; + await saveBluetoothLog(entry); + }, []); + + const logConnectionAttempt = useCallback( + async (deviceId: string, deviceName?: string, isAutoReconnect: boolean = false): Promise => { + // Start a new session + const sessionId = await startSession(deviceId, deviceName); + currentSessionIdRef.current = sessionId; + + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: isAutoReconnect ? BluetoothEvent.AUTO_RECONNECT_ATTEMPT : BluetoothEvent.CONNECT_ATTEMPT, + details: { + deviceId: truncateDeviceId(deviceId), + deviceName, + isAutoReconnect, + }, + sessionId, + }; + await saveBluetoothLog(entry); + + return sessionId; + }, + [] + ); + + const logConnectionSuccess = useCallback( + async (deviceId: string, deviceName?: string, sessionId?: string): Promise => { + const activeSessionId = sessionId || currentSessionIdRef.current; + + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.CONNECT_SUCCESS, + details: { + deviceId: truncateDeviceId(deviceId), + deviceName, + success: true, + }, + sessionId: activeSessionId || undefined, + }; + await saveBluetoothLog(entry); + + // Update session status + if (activeSessionId) { + await updateSession({ status: 'active' }); + } + }, + [] + ); + + const logConnectionFailure = useCallback( + async (deviceId: string, errorMessage: string, sessionId?: string): Promise => { + const activeSessionId = sessionId || currentSessionIdRef.current; + + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.CONNECT_FAILURE, + details: { + deviceId: truncateDeviceId(deviceId), + errorMessage, + success: false, + }, + sessionId: activeSessionId || undefined, + }; + await saveBluetoothLog(entry); + + // Update session status + if (activeSessionId) { + await updateSession({ status: 'failed' }); + } + + // Clear session reference + currentSessionIdRef.current = null; + }, + [] + ); + + const logDisconnect = useCallback(async (deviceId: string, deviceName?: string): Promise => { + // End the current session (which will log the disconnect event with duration) + await endCurrentSession(); + + // Clear session reference + currentSessionIdRef.current = null; + }, []); + + const logStateChange = useCallback(async (deviceId: string, state: string): Promise => { + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.STATE_CHANGE, + details: { + deviceId: truncateDeviceId(deviceId), + state, + }, + sessionId: currentSessionIdRef.current || undefined, + }; + await saveBluetoothLog(entry); + }, []); + + const logError = useCallback(async (deviceId: string, errorMessage: string): Promise => { + const entry: BluetoothLogEntry = { + id: generateId(), + timestamp: Date.now(), + event: BluetoothEvent.ERROR, + details: { + deviceId: truncateDeviceId(deviceId), + errorMessage, + }, + sessionId: currentSessionIdRef.current || undefined, + }; + await saveBluetoothLog(entry); + + // Update session error count + if (currentSessionIdRef.current) { + const currentSession = await import('../utils/bluetoothLogger').then(m => m.getCurrentSession()); + const session = await currentSession; + if (session) { + await updateSession({ totalErrors: session.totalErrors + 1 }); + } + } + }, []); + + return { + logScanStart, + logScanStop, + logDeviceFound, + logConnectionAttempt, + logConnectionSuccess, + logConnectionFailure, + logDisconnect, + logStateChange, + logError, + }; +}; diff --git a/app/app/hooks/useBluetoothManager.ts b/app/src/hooks/useBluetoothManager.ts similarity index 100% rename from app/app/hooks/useBluetoothManager.ts rename to app/src/hooks/useBluetoothManager.ts diff --git a/app/app/hooks/useDeviceConnection.ts b/app/src/hooks/useDeviceConnection.ts similarity index 79% rename from app/app/hooks/useDeviceConnection.ts rename to app/src/hooks/useDeviceConnection.ts index e729169e..ad4b442a 100644 --- a/app/app/hooks/useDeviceConnection.ts +++ b/app/src/hooks/useDeviceConnection.ts @@ -1,6 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { Alert } from 'react-native'; import { OmiConnection, BleAudioCodec, OmiDevice } from 'friend-lite-react-native'; +import { useBluetoothLogger } from './useBluetoothLogger'; interface UseDeviceConnection { connectedDevice: OmiDevice | null; @@ -25,24 +26,39 @@ export const useDeviceConnection = ( const [batteryLevel, setBatteryLevel] = useState(-1); const [connectedDeviceId, setConnectedDeviceId] = useState(null); + // Bluetooth logger + const bluetoothLogger = useBluetoothLogger(); + const currentSessionIdRef = useRef(null); + const handleConnectionStateChange = useCallback((id: string, state: string) => { console.log(`Device ${id} connection state: ${state}`); const isNowConnected = state === 'connected'; setIsConnecting(false); + // Log state change + bluetoothLogger.logStateChange(id, state); + if (isNowConnected) { setConnectedDeviceId(id); + + // Log successful connection + bluetoothLogger.logConnectionSuccess(id, undefined, currentSessionIdRef.current || undefined); + // Potentially fetch the device details from omiConnection if needed to set connectedDevice // For now, we'll assume the app manages the full OmiDevice object elsewhere or doesn't need it here. if (onConnect) onConnect(); } else { + // Log disconnect + bluetoothLogger.logDisconnect(id); + currentSessionIdRef.current = null; + setConnectedDeviceId(null); setConnectedDevice(null); setCurrentCodec(null); setBatteryLevel(-1); - if (onDisconnect) onDisconnect(); + if (onDisconnect) onDisconnect(); } - }, [onDisconnect, onConnect]); + }, [onDisconnect, onConnect, bluetoothLogger]); const connectToDevice = useCallback(async (deviceId: string) => { if (connectedDeviceId && connectedDeviceId !== deviceId) { @@ -59,6 +75,10 @@ export const useDeviceConnection = ( setCurrentCodec(null); setBatteryLevel(-1); + // Log connection attempt and get session ID + const sessionId = await bluetoothLogger.logConnectionAttempt(deviceId); + currentSessionIdRef.current = sessionId; + try { const success = await omiConnection.connect(deviceId, handleConnectionStateChange); if (success) { @@ -66,6 +86,9 @@ export const useDeviceConnection = ( // Note: actual connected state is set by handleConnectionStateChange callback } else { setIsConnecting(false); + // Log connection failure + await bluetoothLogger.logConnectionFailure(deviceId, 'Connection returned false', sessionId); + currentSessionIdRef.current = null; Alert.alert('Connection Failed', 'Could not connect to the device. Please try again.'); } } catch (error) { @@ -73,9 +96,14 @@ export const useDeviceConnection = ( setIsConnecting(false); setConnectedDevice(null); setConnectedDeviceId(null); + + // Log connection error + await bluetoothLogger.logConnectionFailure(deviceId, String(error), sessionId); + currentSessionIdRef.current = null; + Alert.alert('Connection Error', String(error)); } - }, [omiConnection, handleConnectionStateChange, connectedDeviceId]); // Added connectedDeviceId + }, [omiConnection, handleConnectionStateChange, connectedDeviceId, bluetoothLogger]); const disconnectFromDevice = useCallback(async () => { console.log('Attempting to disconnect...'); @@ -91,8 +119,15 @@ export const useDeviceConnection = ( setCurrentCodec(null); setBatteryLevel(-1); // The handleConnectionStateChange should also be triggered by the SDK upon disconnection + // Logging will happen in handleConnectionStateChange } catch (error) { console.error('Disconnect error:', error); + + // Log disconnect error + if (connectedDeviceId) { + await bluetoothLogger.logError(connectedDeviceId, `Disconnect error: ${String(error)}`); + } + Alert.alert('Disconnect Error', String(error)); // Even if disconnect fails, reset state as we intend to be disconnected setConnectedDevice(null); @@ -100,7 +135,7 @@ export const useDeviceConnection = ( setCurrentCodec(null); setBatteryLevel(-1); } - }, [omiConnection, onDisconnect]); + }, [omiConnection, onDisconnect, connectedDeviceId, bluetoothLogger]); const getAudioCodec = useCallback(async () => { if (!omiConnection.isConnected() || !connectedDeviceId) { diff --git a/app/app/hooks/useDeviceScanning.ts b/app/src/hooks/useDeviceScanning.ts similarity index 100% rename from app/app/hooks/useDeviceScanning.ts rename to app/src/hooks/useDeviceScanning.ts diff --git a/app/app/hooks/usePhoneAudioRecorder.ts b/app/src/hooks/usePhoneAudioRecorder.ts similarity index 100% rename from app/app/hooks/usePhoneAudioRecorder.ts rename to app/src/hooks/usePhoneAudioRecorder.ts diff --git a/app/src/utils/bluetoothLogger.ts b/app/src/utils/bluetoothLogger.ts new file mode 100644 index 00000000..34d77ad3 --- /dev/null +++ b/app/src/utils/bluetoothLogger.ts @@ -0,0 +1,299 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Storage keys +const BLUETOOTH_LOGS_KEY = 'BLUETOOTH_LOGS'; +const BLUETOOTH_STATS_KEY = 'BLUETOOTH_STATS'; +const CURRENT_SESSION_KEY = 'BLUETOOTH_CURRENT_SESSION'; +const MAX_LOG_ENTRIES = 500; +const LOG_RETENTION_DAYS = 7; + +// Enums +export enum BluetoothEvent { + SCAN_START = 'scan_start', + SCAN_STOP = 'scan_stop', + DEVICE_FOUND = 'device_found', + CONNECT_ATTEMPT = 'connect_attempt', + CONNECT_SUCCESS = 'connect_success', + CONNECT_FAILURE = 'connect_failure', + DISCONNECT = 'disconnect', + AUTO_RECONNECT_ATTEMPT = 'auto_reconnect_attempt', + AUTO_RECONNECT_SUCCESS = 'auto_reconnect_success', + AUTO_RECONNECT_FAILURE = 'auto_reconnect_failure', + STATE_CHANGE = 'state_change', + ERROR = 'error', +} + +// Interfaces +export interface BluetoothLogEntry { + id: string; + timestamp: number; + event: BluetoothEvent; + details: { + deviceId?: string; + deviceName?: string; + state?: string; + errorMessage?: string; + duration?: number; + success?: boolean; + isAutoReconnect?: boolean; + }; + sessionId?: string; +} + +export interface BluetoothSession { + sessionId: string; + deviceId: string; + deviceName?: string; + startTime: number; + endTime?: number; + status: 'active' | 'completed' | 'failed'; + totalConnectAttempts: number; + totalDisconnects: number; + totalErrors: number; + connectionDuration?: number; +} + +export interface BluetoothStats { + totalSessions: number; + totalConnections: number; + successfulConnections: number; + failedConnections: number; + totalDisconnects: number; + averageSessionDuration: number; + lastConnectionTime?: number; + currentSession?: BluetoothSession; +} + +// Helper functions +export const generateId = (): string => { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; + +export const truncateDeviceId = (deviceId: string): string => { + // Keep only last 8 characters for privacy + if (deviceId.length > 8) { + return `...${deviceId.slice(-8)}`; + } + return deviceId; +}; + +export const isLogExpired = (timestamp: number): boolean => { + const now = Date.now(); + const dayInMs = 24 * 60 * 60 * 1000; + return now - timestamp > LOG_RETENTION_DAYS * dayInMs; +}; + +// Storage functions +export const saveBluetoothLog = async (entry: BluetoothLogEntry): Promise => { + try { + const existingLogs = await getBluetoothLogs(); + + // Filter out expired logs + const validLogs = existingLogs.filter(log => !isLogExpired(log.timestamp)); + + // Add new log and limit to MAX_LOG_ENTRIES + const newLogs = [entry, ...validLogs].slice(0, MAX_LOG_ENTRIES); + + await AsyncStorage.setItem(BLUETOOTH_LOGS_KEY, JSON.stringify(newLogs)); + console.log('[BluetoothLogger] Log saved:', entry.event); + + // Update stats + await updateBluetoothStats(entry); + } catch (error) { + console.error('[BluetoothLogger] Error saving log:', error); + } +}; + +export const getBluetoothLogs = async (): Promise => { + try { + const logs = await AsyncStorage.getItem(BLUETOOTH_LOGS_KEY); + if (!logs) return []; + + const parsedLogs = JSON.parse(logs); + + // Filter out expired logs + return parsedLogs.filter((log: BluetoothLogEntry) => !isLogExpired(log.timestamp)); + } catch (error) { + console.error('[BluetoothLogger] Error reading logs:', error); + return []; + } +}; + +export const clearBluetoothLogs = async (): Promise => { + try { + await AsyncStorage.removeItem(BLUETOOTH_LOGS_KEY); + await AsyncStorage.removeItem(BLUETOOTH_STATS_KEY); + await AsyncStorage.removeItem(CURRENT_SESSION_KEY); + console.log('[BluetoothLogger] All logs and stats cleared'); + } catch (error) { + console.error('[BluetoothLogger] Error clearing logs:', error); + } +}; + +export const getBluetoothStats = async (): Promise => { + try { + const statsJson = await AsyncStorage.getItem(BLUETOOTH_STATS_KEY); + if (!statsJson) { + return { + totalSessions: 0, + totalConnections: 0, + successfulConnections: 0, + failedConnections: 0, + totalDisconnects: 0, + averageSessionDuration: 0, + }; + } + return JSON.parse(statsJson); + } catch (error) { + console.error('[BluetoothLogger] Error reading stats:', error); + return { + totalSessions: 0, + totalConnections: 0, + successfulConnections: 0, + failedConnections: 0, + totalDisconnects: 0, + averageSessionDuration: 0, + }; + } +}; + +export const updateBluetoothStats = async (entry: BluetoothLogEntry): Promise => { + try { + const stats = await getBluetoothStats(); + + switch (entry.event) { + case BluetoothEvent.CONNECT_ATTEMPT: + stats.totalConnections++; + break; + + case BluetoothEvent.CONNECT_SUCCESS: + case BluetoothEvent.AUTO_RECONNECT_SUCCESS: + stats.successfulConnections++; + stats.lastConnectionTime = entry.timestamp; + break; + + case BluetoothEvent.CONNECT_FAILURE: + case BluetoothEvent.AUTO_RECONNECT_FAILURE: + stats.failedConnections++; + break; + + case BluetoothEvent.DISCONNECT: + stats.totalDisconnects++; + + // Update average session duration if duration is provided + if (entry.details.duration) { + const totalSessions = stats.totalSessions || 1; + const currentAvg = stats.averageSessionDuration || 0; + stats.averageSessionDuration = + (currentAvg * (totalSessions - 1) + entry.details.duration) / totalSessions; + } + break; + } + + await AsyncStorage.setItem(BLUETOOTH_STATS_KEY, JSON.stringify(stats)); + } catch (error) { + console.error('[BluetoothLogger] Error updating stats:', error); + } +}; + +export const getCurrentSession = async (): Promise => { + try { + const sessionJson = await AsyncStorage.getItem(CURRENT_SESSION_KEY); + if (!sessionJson) return null; + return JSON.parse(sessionJson); + } catch (error) { + console.error('[BluetoothLogger] Error reading current session:', error); + return null; + } +}; + +export const startSession = async (deviceId: string, deviceName?: string): Promise => { + try { + const sessionId = generateId(); + const session: BluetoothSession = { + sessionId, + deviceId, + deviceName, + startTime: Date.now(), + status: 'active', + totalConnectAttempts: 1, + totalDisconnects: 0, + totalErrors: 0, + }; + + await AsyncStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(session)); + + // Update stats + const stats = await getBluetoothStats(); + stats.totalSessions++; + await AsyncStorage.setItem(BLUETOOTH_STATS_KEY, JSON.stringify(stats)); + + console.log('[BluetoothLogger] Session started:', sessionId); + return sessionId; + } catch (error) { + console.error('[BluetoothLogger] Error starting session:', error); + return generateId(); + } +}; + +export const updateSession = async (updates: Partial): Promise => { + try { + const session = await getCurrentSession(); + if (!session) return; + + const updatedSession = { ...session, ...updates }; + await AsyncStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(updatedSession)); + } catch (error) { + console.error('[BluetoothLogger] Error updating session:', error); + } +}; + +export const endCurrentSession = async (): Promise => { + try { + const session = await getCurrentSession(); + if (!session) return; + + const endTime = Date.now(); + const duration = endTime - session.startTime; + + const completedSession: BluetoothSession = { + ...session, + endTime, + connectionDuration: duration, + status: 'completed', + }; + + // Save completed session as a log entry for history + const logEntry: BluetoothLogEntry = { + id: generateId(), + timestamp: endTime, + event: BluetoothEvent.DISCONNECT, + details: { + deviceId: truncateDeviceId(session.deviceId), + deviceName: session.deviceName, + duration, + }, + sessionId: session.sessionId, + }; + + await saveBluetoothLog(logEntry); + + // Clear current session + await AsyncStorage.removeItem(CURRENT_SESSION_KEY); + + console.log('[BluetoothLogger] Session ended:', session.sessionId, 'Duration:', duration); + } catch (error) { + console.error('[BluetoothLogger] Error ending session:', error); + } +}; + +export const getRecentLogs = async (limit: number = 20): Promise => { + const logs = await getBluetoothLogs(); + return logs.slice(0, limit); +}; + +export const getLogsInLastHours = async (hours: number): Promise => { + const logs = await getBluetoothLogs(); + const cutoffTime = Date.now() - hours * 60 * 60 * 1000; + return logs.filter(log => log.timestamp >= cutoffTime); +}; diff --git a/app/app/utils/storage.ts b/app/src/utils/storage.ts similarity index 100% rename from app/app/utils/storage.ts rename to app/src/utils/storage.ts