From 0e9df5c2dad71d02037d54f1a3f915bd652b2ab6 Mon Sep 17 00:00:00 2001
From: Ankush Malaker <43288948+AnkushMalaker@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:44:09 +0530
Subject: [PATCH] Refactor application configuration and enhance user interface
- Updated app.json and package.json to rename the application from "friend-lite-app" to "chronicle" and adjusted related identifiers.
- Enhanced the app layout by integrating a ConnectionLogProvider for better state management and user experience.
- Introduced a new diagnostics screen for monitoring connection logs and events, improving troubleshooting capabilities.
- Updated TypeScript configuration to support path aliases for cleaner imports.
- Added a new plugin for App Transport Security settings to enhance network security.
- Removed deprecated components and streamlined the codebase for improved maintainability.
---
app/app.json | 21 +-
app/app/_layout.tsx | 20 +-
app/app/components/BackendStatus.tsx | 319 -------
app/app/components/DeviceListItem.tsx | 107 ---
app/app/components/ObsidianIngest.tsx | 154 ----
app/app/components/PhoneAudioButton.tsx | 201 -----
app/app/diagnostics.tsx | 146 ++++
app/app/index.tsx | 801 +++++-------------
app/package-lock.json | 775 ++++++++++++++++-
app/package.json | 9 +-
app/plugins/with-ats.js | 10 +
app/{app => src}/components/AuthSection.tsx | 92 +-
app/src/components/BackendStatus.tsx | 244 ++++++
.../components/BluetoothStatusBanner.tsx | 50 +-
app/{app => src}/components/DeviceDetails.tsx | 229 +++--
app/src/components/DeviceListItem.tsx | 111 +++
app/src/components/ObsidianIngest.tsx | 140 +++
app/src/components/PhoneAudioButton.tsx | 150 ++++
app/{app => src}/components/ScanControls.tsx | 33 +-
app/src/components/SignalStrength.tsx | 52 ++
.../components/StatusIndicator.tsx | 0
app/src/contexts/ConnectionLogContext.tsx | 73 ++
app/{app => src}/hooks/.gitkeep | 0
app/src/hooks/useAppSettings.ts | 80 ++
app/{app => src}/hooks/useAudioListener.ts | 6 +-
app/{app => src}/hooks/useAudioStreamer.ts | 2 +-
.../hooks/useAudioStreamingOrchestrator.ts | 158 ++++
app/src/hooks/useAutoReconnect.ts | 106 +++
app/src/hooks/useBatteryMonitor.ts | 110 +++
app/{app => src}/hooks/useBluetoothManager.ts | 4 +-
app/{app => src}/hooks/useDeviceConnection.ts | 54 +-
app/{app => src}/hooks/useDeviceScanning.ts | 23 +-
.../hooks/usePhoneAudioRecorder.ts | 7 +-
app/src/theme.ts | 58 ++
app/{app => src}/utils/storage.ts | 0
app/tsconfig.json | 6 +-
36 files changed, 2705 insertions(+), 1646 deletions(-)
delete mode 100644 app/app/components/BackendStatus.tsx
delete mode 100644 app/app/components/DeviceListItem.tsx
delete mode 100644 app/app/components/ObsidianIngest.tsx
delete mode 100644 app/app/components/PhoneAudioButton.tsx
create mode 100644 app/app/diagnostics.tsx
create mode 100644 app/plugins/with-ats.js
rename app/{app => src}/components/AuthSection.tsx (72%)
create mode 100644 app/src/components/BackendStatus.tsx
rename app/{app => src}/components/BluetoothStatusBanner.tsx (67%)
rename app/{app => src}/components/DeviceDetails.tsx (51%)
create mode 100644 app/src/components/DeviceListItem.tsx
create mode 100644 app/src/components/ObsidianIngest.tsx
create mode 100644 app/src/components/PhoneAudioButton.tsx
rename app/{app => src}/components/ScanControls.tsx (63%)
create mode 100644 app/src/components/SignalStrength.tsx
rename app/{app => src}/components/StatusIndicator.tsx (100%)
create mode 100644 app/src/contexts/ConnectionLogContext.tsx
rename app/{app => src}/hooks/.gitkeep (100%)
create mode 100644 app/src/hooks/useAppSettings.ts
rename app/{app => src}/hooks/useAudioListener.ts (97%)
rename app/{app => src}/hooks/useAudioStreamer.ts (99%)
create mode 100644 app/src/hooks/useAudioStreamingOrchestrator.ts
create mode 100644 app/src/hooks/useAutoReconnect.ts
create mode 100644 app/src/hooks/useBatteryMonitor.ts
rename app/{app => src}/hooks/useBluetoothManager.ts (96%)
rename app/{app => src}/hooks/useDeviceConnection.ts (73%)
rename app/{app => src}/hooks/useDeviceScanning.ts (87%)
rename app/{app => src}/hooks/usePhoneAudioRecorder.ts (97%)
create mode 100644 app/src/theme.ts
rename app/{app => src}/utils/storage.ts (100%)
diff --git a/app/app.json b/app/app.json
index 66fbb8c2..2315b46b 100644
--- a/app/app.json
+++ b/app/app.json
@@ -1,12 +1,12 @@
{
"expo": {
- "name": "friend-lite-app",
- "slug": "friend-lite-app",
+ "name": "chronicle",
+ "slug": "chronicle",
"version": "1.0.0",
+ "scheme": "chronicle",
"orientation": "portrait",
"icon": "./assets/icon.png",
- "entryPoint": "./app/index.tsx",
- "userInterfaceStyle": "light",
+ "userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
@@ -17,9 +17,13 @@
],
"ios": {
"supportsTablet": true,
- "bundleIdentifier": "com.cupbearer5517.friendlite",
+ "bundleIdentifier": "com.cupbearer5517.chronicle",
"infoPlist": {
- "NSMicrophoneUsageDescription": "Friend Lite needs access to your microphone to stream audio to the backend for processing."
+ "NSMicrophoneUsageDescription": "Chronicle needs access to your microphone to stream audio to the backend for processing.",
+ "NSAppTransportSecurity": {
+ "NSAllowsArbitraryLoads": true,
+ "NSAllowsLocalNetworking": true
+ }
}
},
"android": {
@@ -27,7 +31,7 @@
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
- "package": "com.cupbearer5517.friendlite",
+ "package": "com.cupbearer5517.chronicle",
"permissions": [
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
@@ -91,7 +95,8 @@
]
}
}
- ]
+ ],
+ "./plugins/with-ats"
],
"extra": {
"eas": {
diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx
index d2a8b0bc..b8110d09 100644
--- a/app/app/_layout.tsx
+++ b/app/app/_layout.tsx
@@ -1,5 +1,23 @@
import { Stack } from "expo-router";
+import { useTheme } from "@/theme";
+import { ConnectionLogProvider } from "@/contexts/ConnectionLogContext";
export default function RootLayout() {
- return ;
+ const { colors, isDark } = useTheme();
+
+ return (
+
+
+
+
+
+
+ );
}
diff --git a/app/app/components/BackendStatus.tsx b/app/app/components/BackendStatus.tsx
deleted file mode 100644
index 4f55d37f..00000000
--- a/app/app/components/BackendStatus.tsx
+++ /dev/null
@@ -1,319 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
-
-interface BackendStatusProps {
- backendUrl: string;
- onBackendUrlChange: (url: string) => void;
- jwtToken: string | null;
-}
-
-interface HealthStatus {
- status: 'unknown' | 'checking' | 'healthy' | 'unhealthy' | 'auth_required';
- message: string;
- lastChecked?: Date;
-}
-
-export const BackendStatus: React.FC = ({
- backendUrl,
- onBackendUrlChange,
- jwtToken,
-}) => {
- const [healthStatus, setHealthStatus] = useState({
- status: 'unknown',
- message: 'Not checked',
- });
-
- const checkBackendHealth = async (showAlert: boolean = false) => {
- if (!backendUrl.trim()) {
- setHealthStatus({
- status: 'unhealthy',
- message: 'Backend URL not set',
- });
- return;
- }
-
- setHealthStatus({
- status: 'checking',
- message: 'Checking connection...',
- });
-
- try {
- // Convert WebSocket URL to HTTP URL for health check
- let baseUrl = backendUrl.trim();
-
- // Handle different URL formats
- if (baseUrl.startsWith('ws://')) {
- baseUrl = baseUrl.replace('ws://', 'http://');
- } else if (baseUrl.startsWith('wss://')) {
- baseUrl = baseUrl.replace('wss://', 'https://');
- }
-
- // Remove any WebSocket path if present
- baseUrl = baseUrl.split('/ws')[0];
-
- // Try health endpoint first
- const healthUrl = `${baseUrl}/health`;
- console.log('[BackendStatus] Checking health at:', healthUrl);
-
- const response = await fetch(healthUrl, {
- method: 'GET',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- ...(jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}),
- },
- });
-
- console.log('[BackendStatus] Health check response status:', response.status);
-
- if (response.ok) {
- const healthData = await response.json();
- setHealthStatus({
- status: 'healthy',
- message: `Connected (${healthData.status || 'OK'})`,
- lastChecked: new Date(),
- });
-
- if (showAlert) {
- Alert.alert('Connection Success', 'Successfully connected to backend!');
- }
- } else if (response.status === 401 || response.status === 403) {
- setHealthStatus({
- status: 'auth_required',
- message: 'Authentication required',
- lastChecked: new Date(),
- });
-
- if (showAlert) {
- Alert.alert('Authentication Required', 'Please login to access the backend.');
- }
- } else {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
- } catch (error) {
- console.error('[BackendStatus] Health check error:', error);
-
- let errorMessage = 'Connection failed';
- if (error instanceof Error) {
- if (error.message.includes('Network request failed')) {
- errorMessage = 'Network request failed - check URL and network connection';
- } else if (error.name === 'AbortError') {
- errorMessage = 'Request timeout';
- } else {
- errorMessage = error.message;
- }
- }
-
- setHealthStatus({
- status: 'unhealthy',
- message: errorMessage,
- lastChecked: new Date(),
- });
-
- if (showAlert) {
- Alert.alert(
- 'Connection Failed',
- `Could not connect to backend: ${errorMessage}\n\nMake sure the backend is running and accessible.`
- );
- }
- }
- };
-
- // Auto-check health when backend URL or JWT token changes
- useEffect(() => {
- if (backendUrl.trim()) {
- const timer = setTimeout(() => {
- checkBackendHealth(false);
- }, 500); // Debounce
-
- return () => clearTimeout(timer);
- }
- }, [backendUrl, jwtToken]);
-
- const getStatusColor = (status: HealthStatus['status']): string => {
- switch (status) {
- case 'healthy':
- return '#4CD964';
- case 'checking':
- return '#FF9500';
- case 'unhealthy':
- return '#FF3B30';
- case 'auth_required':
- return '#FF9500';
- default:
- return '#8E8E93';
- }
- };
-
- const getStatusIcon = (status: HealthStatus['status']): string => {
- switch (status) {
- case 'healthy':
- return 'β
';
- case 'checking':
- return 'π';
- case 'unhealthy':
- return 'β';
- case 'auth_required':
- return 'π';
- default:
- return 'β';
- }
- };
-
- return (
-
- Backend Connection
-
- Backend URL:
-
-
-
-
- Status:
-
- {getStatusIcon(healthStatus.status)}
-
- {healthStatus.message}
-
- {healthStatus.status === 'checking' && (
-
- )}
-
-
-
- {healthStatus.lastChecked && (
-
- Last checked: {healthStatus.lastChecked.toLocaleTimeString()}
-
- )}
-
-
- checkBackendHealth(true)}
- disabled={healthStatus.status === 'checking'}
- >
-
- {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'}
-
-
-
-
- Enter the WebSocket URL of your backend server. Simple backend: http://localhost:8000/ (no auth).
- Advanced backend: http://localhost:8080/ (requires login). Status is automatically checked.
- The websocket URL can be different or the same as the HTTP URL, with /ws endpoint and codec parameter (e.g., /ws?codec=pcm)
-
-
- );
-};
-
-const styles = StyleSheet.create({
- section: {
- marginBottom: 25,
- 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',
- marginBottom: 15,
- color: '#333',
- },
- inputLabel: {
- fontSize: 14,
- color: '#333',
- marginBottom: 5,
- fontWeight: '500',
- },
- textInput: {
- backgroundColor: '#f0f0f0',
- borderWidth: 1,
- borderColor: '#ddd',
- borderRadius: 6,
- padding: 10,
- fontSize: 14,
- width: '100%',
- marginBottom: 15,
- color: '#333',
- },
- statusContainer: {
- marginBottom: 15,
- padding: 10,
- backgroundColor: '#f8f9fa',
- borderRadius: 6,
- borderWidth: 1,
- borderColor: '#e9ecef',
- },
- statusRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- statusLabel: {
- fontSize: 14,
- fontWeight: '500',
- color: '#333',
- },
- statusValue: {
- flexDirection: 'row',
- alignItems: 'center',
- flex: 1,
- justifyContent: 'flex-end',
- },
- statusIcon: {
- fontSize: 16,
- marginRight: 6,
- },
- statusText: {
- fontSize: 14,
- fontWeight: '500',
- },
- lastCheckedText: {
- fontSize: 12,
- color: '#666',
- marginTop: 5,
- textAlign: 'center',
- fontStyle: 'italic',
- },
- button: {
- backgroundColor: '#007AFF',
- paddingVertical: 12,
- paddingHorizontal: 20,
- borderRadius: 8,
- alignItems: 'center',
- marginBottom: 10,
- elevation: 2,
- },
- buttonDisabled: {
- backgroundColor: '#A0A0A0',
- opacity: 0.7,
- },
- buttonText: {
- color: 'white',
- fontSize: 16,
- fontWeight: '600',
- },
- helpText: {
- fontSize: 12,
- color: '#666',
- textAlign: 'center',
- fontStyle: 'italic',
- },
-});
-
-export default BackendStatus;
\ No newline at end of file
diff --git a/app/app/components/DeviceListItem.tsx b/app/app/components/DeviceListItem.tsx
deleted file mode 100644
index a8083035..00000000
--- a/app/app/components/DeviceListItem.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
-import { OmiDevice } from 'friend-lite-react-native';
-
-interface DeviceListItemProps {
- device: OmiDevice;
- onConnect: (deviceId: string) => void;
- onDisconnect: () => void;
- isConnecting: boolean;
- connectedDeviceId: string | null;
-}
-
-export const DeviceListItem: React.FC = ({
- device,
- onConnect,
- onDisconnect,
- isConnecting,
- connectedDeviceId
-}) => {
- const isThisDeviceConnected = connectedDeviceId === device.id;
- const isAnotherDeviceConnected = connectedDeviceId !== null && connectedDeviceId !== device.id;
-
- return (
-
-
- {device.name || 'Unknown Device'}
- ID: {device.id}
- {device.rssi != null && RSSI: {device.rssi} dBm}
-
- {
- isThisDeviceConnected ? (
-
- {isConnecting ? 'Disconnecting...' : 'Disconnect'}
-
- ) : (
- onConnect(device.id)}
- disabled={isConnecting || isAnotherDeviceConnected} // Disable if connecting to this/another device or another device is connected
- >
- {isConnecting && connectedDeviceId === device.id ? 'Connecting...' : 'Connect'}
-
- )
- }
-
- );
-};
-
-const styles = StyleSheet.create({
- deviceItem: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingVertical: 12,
- paddingHorizontal: 5, // Added some horizontal padding
- borderBottomWidth: 1,
- borderBottomColor: '#eee',
- },
- deviceInfoContainer: {
- flex: 1, // Allow text to take available space and wrap if needed
- marginRight: 10, // Space between text and button
- },
- deviceName: {
- fontSize: 16,
- fontWeight: '500',
- color: '#333',
- },
- deviceInfo: {
- fontSize: 12,
- color: '#666',
- marginTop: 2,
- },
- button: {
- backgroundColor: '#007AFF',
- paddingVertical: 12,
- paddingHorizontal: 20,
- borderRadius: 8,
- alignItems: 'center',
- elevation: 1,
- },
- smallButton: {
- paddingVertical: 8,
- paddingHorizontal: 12,
- },
- buttonDanger: {
- backgroundColor: '#FF3B30',
- },
- buttonDisabled: {
- backgroundColor: '#A0A0A0',
- opacity: 0.7,
- },
- buttonText: {
- color: 'white',
- fontSize: 14, // Slightly smaller for small buttons
- fontWeight: '600',
- },
-});
-
-export default DeviceListItem;
\ No newline at end of file
diff --git a/app/app/components/ObsidianIngest.tsx b/app/app/components/ObsidianIngest.tsx
deleted file mode 100644
index d14ca367..00000000
--- a/app/app/components/ObsidianIngest.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-
-import React, { useState } from 'react';
-import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
-
-interface ObsidianIngestProps {
- backendUrl: string;
- jwtToken: string | null;
-}
-
-export const ObsidianIngest: React.FC = ({
- backendUrl,
- jwtToken,
-}) => {
- const [vaultPath, setVaultPath] = useState('/app/data/obsidian_vault');
- const [loading, setLoading] = useState(false);
-
- const handleIngest = async () => {
- if (!backendUrl) {
- Alert.alert("Error", "Backend URL not set");
- return;
- }
-
- if (!jwtToken) {
- Alert.alert("Authentication Required", "Please login to ingest Obsidian vault.");
- return;
- }
-
- setLoading(true);
- try {
- let baseUrl = backendUrl.trim();
- // Handle different URL formats
- if (baseUrl.startsWith('ws://')) {
- baseUrl = baseUrl.replace('ws://', 'http://');
- } else if (baseUrl.startsWith('wss://')) {
- baseUrl = baseUrl.replace('wss://', 'https://');
- }
- baseUrl = baseUrl.split('/ws')[0];
-
- const response = await fetch(`${baseUrl}/api/obsidian/ingest`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${jwtToken}`
- },
- body: JSON.stringify({ vault_path: vaultPath })
- });
-
- if (response.ok) {
- Alert.alert("Success", "Ingestion started in background.");
- } else {
- const errorText = await response.text();
- Alert.alert("Error", `Ingestion failed: ${response.status} - ${errorText}`);
- }
- } catch (e) {
- Alert.alert("Error", `Network request failed: ${e}`);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- Obsidian Ingestion
-
- Vault Path (Backend Container):
-
-
-
-
- {loading ? 'Starting Ingestion...' : 'Ingest to Neo4j'}
-
-
-
-
- Enter the absolute path to the Obsidian vault INSIDE the backend container.
- Ensure the folder is mounted to the container.
-
-
- );
-};
-
-const styles = StyleSheet.create({
- section: {
- marginBottom: 25,
- 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',
- marginBottom: 15,
- color: '#333',
- },
- inputLabel: {
- fontSize: 14,
- color: '#333',
- marginBottom: 5,
- fontWeight: '500',
- },
- textInput: {
- backgroundColor: '#f0f0f0',
- borderWidth: 1,
- borderColor: '#ddd',
- borderRadius: 6,
- padding: 10,
- fontSize: 14,
- width: '100%',
- marginBottom: 15,
- color: '#333',
- },
- button: {
- backgroundColor: '#9b59b6', // Purple for Obsidian
- paddingVertical: 12,
- paddingHorizontal: 20,
- borderRadius: 8,
- alignItems: 'center',
- marginBottom: 10,
- elevation: 2,
- },
- buttonDisabled: {
- backgroundColor: '#A0A0A0',
- opacity: 0.7,
- },
- buttonText: {
- color: 'white',
- fontSize: 16,
- fontWeight: '600',
- },
- helpText: {
- fontSize: 12,
- color: '#666',
- textAlign: 'center',
- fontStyle: 'italic',
- },
-});
-
-export default ObsidianIngest;
diff --git a/app/app/components/PhoneAudioButton.tsx b/app/app/components/PhoneAudioButton.tsx
deleted file mode 100644
index 1f486e55..00000000
--- a/app/app/components/PhoneAudioButton.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-// PhoneAudioButton.tsx
-import React from 'react';
-import {
- TouchableOpacity,
- Text,
- View,
- StyleSheet,
- ActivityIndicator,
-} from 'react-native';
-
-interface PhoneAudioButtonProps {
- isRecording: boolean;
- isInitializing: boolean;
- isDisabled: boolean;
- audioLevel: number;
- error: string | null;
- onPress: () => void;
-}
-
-const PhoneAudioButton: React.FC = ({
- isRecording,
- isInitializing,
- isDisabled,
- audioLevel,
- error,
- onPress,
-}) => {
-
- const getButtonStyle = () => {
- if (isDisabled && !isRecording) {
- return [styles.button, styles.buttonDisabled];
- }
- if (isRecording) {
- return [styles.button, styles.buttonRecording];
- }
- if (error) {
- return [styles.button, styles.buttonError];
- }
- return [styles.button, styles.buttonIdle];
- };
-
- const getButtonText = () => {
- if (isInitializing) {
- return 'Initializing...';
- }
- if (isRecording) {
- return 'Stop Phone Audio';
- }
- return 'Stream Phone Audio';
- };
-
- const getMicrophoneIcon = () => {
- if (isRecording) {
- return 'π€'; // Recording microphone
- }
- return 'ποΈ'; // Idle microphone
- };
-
- return (
-
-
-
- {isInitializing ? (
-
- ) : (
-
- {getMicrophoneIcon()}
- {getButtonText()}
-
- )}
-
-
-
- {/* Audio Level Indicator */}
- {isRecording && (
-
-
-
-
- Audio Level
-
- )}
-
- {/* Status Message */}
- {isRecording && (
-
- Streaming audio to backend...
-
- )}
-
- {/* Error Message */}
- {error && !isRecording && (
- {error}
- )}
-
- {/* Disabled Message */}
- {isDisabled && !isRecording && (
-
- Disconnect Bluetooth device to use phone audio
-
- )}
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- marginVertical: 10,
- paddingHorizontal: 20,
- },
- buttonWrapper: {
- alignSelf: 'stretch',
- },
- button: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 12,
- paddingHorizontal: 20,
- borderRadius: 8,
- minHeight: 48,
- },
- buttonContent: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- },
- buttonIdle: {
- backgroundColor: '#007AFF',
- },
- buttonRecording: {
- backgroundColor: '#FF3B30',
- },
- buttonDisabled: {
- backgroundColor: '#C7C7CC',
- },
- buttonError: {
- backgroundColor: '#FF9500',
- },
- buttonText: {
- color: '#FFFFFF',
- fontSize: 16,
- fontWeight: '600',
- marginLeft: 8,
- },
- icon: {
- fontSize: 20,
- },
- statusText: {
- textAlign: 'center',
- marginTop: 8,
- fontSize: 12,
- color: '#8E8E93',
- },
- errorText: {
- textAlign: 'center',
- marginTop: 8,
- fontSize: 12,
- color: '#FF3B30',
- },
- disabledText: {
- textAlign: 'center',
- marginTop: 8,
- fontSize: 12,
- color: '#8E8E93',
- fontStyle: 'italic',
- },
- audioLevelContainer: {
- marginTop: 12,
- alignItems: 'center',
- },
- audioLevelBackground: {
- width: '100%',
- height: 4,
- backgroundColor: '#E5E5EA',
- borderRadius: 2,
- overflow: 'hidden',
- },
- audioLevelBar: {
- height: '100%',
- backgroundColor: '#34C759',
- borderRadius: 2,
- },
- audioLevelText: {
- marginTop: 4,
- fontSize: 10,
- color: '#8E8E93',
- },
-});
-
-export default PhoneAudioButton;
\ No newline at end of file
diff --git a/app/app/diagnostics.tsx b/app/app/diagnostics.tsx
new file mode 100644
index 00000000..18ade000
--- /dev/null
+++ b/app/app/diagnostics.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView } from 'react-native';
+import { useTheme, ThemeColors } from '@/theme';
+import { useConnectionLog, ConnectionEvent, ConnectionEventType } from '@/contexts/ConnectionLogContext';
+
+const EVENT_BADGE_COLORS: Record = {
+ scan_start: '#007AFF',
+ scan_stop: '#8E8E93',
+ scan_result: '#5856D6',
+ connect_start: '#FF9500',
+ connect_success: '#34C759',
+ connect_fail: '#FF3B30',
+ disconnect: '#FF3B30',
+ battery_read: '#34C759',
+ audio_start: '#007AFF',
+ audio_stop: '#8E8E93',
+ error: '#FF3B30',
+ health_ping: '#34C759',
+ reconnect_attempt: '#FF9500',
+ bt_state_change: '#5856D6',
+};
+
+function formatTime(date: Date): string {
+ return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
+}
+
+function EventItem({ event, colors }: { event: ConnectionEvent; colors: ThemeColors }) {
+ const badgeColor = EVENT_BADGE_COLORS[event.type] || colors.textTertiary;
+
+ return (
+
+ {formatTime(event.timestamp)}
+
+ {event.type.replace(/_/g, ' ')}
+
+
+ {event.deviceName && {event.deviceName}}
+ {event.details && {event.details}}
+ {event.rssi != null && RSSI: {event.rssi} dBm}
+
+
+ );
+}
+
+const itemStyles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderBottomWidth: 1,
+ },
+ time: {
+ fontSize: 11,
+ fontFamily: 'monospace',
+ width: 65,
+ marginTop: 3,
+ },
+ badge: {
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 4,
+ marginRight: 8,
+ marginTop: 2,
+ },
+ badgeText: {
+ color: 'white',
+ fontSize: 10,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ },
+ details: {
+ flex: 1,
+ },
+ device: {
+ fontSize: 13,
+ fontWeight: '500',
+ },
+ detail: {
+ fontSize: 12,
+ marginTop: 1,
+ },
+});
+
+export default function DiagnosticsScreen() {
+ const { colors } = useTheme();
+ const { events, clearEvents } = useConnectionLog();
+
+ return (
+
+
+ Connection Log ({events.length})
+
+ Clear
+
+
+
+ {events.length === 0 ? (
+
+ No events recorded yet. Scan or connect a device to see events here.
+
+ ) : (
+ }
+ keyExtractor={(item) => item.id}
+ style={{ backgroundColor: colors.card }}
+ />
+ )}
+
+ );
+}
+
+const screenStyles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ },
+ title: {
+ fontSize: 17,
+ fontWeight: '600',
+ },
+ clearButton: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 6,
+ },
+ clearText: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ empty: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 40,
+ },
+ emptyText: {
+ fontSize: 15,
+ textAlign: 'center',
+ },
+});
diff --git a/app/app/index.tsx b/app/app/index.tsx
index 649a2e2b..636e475e 100644
--- a/app/app/index.tsx
+++ b/app/app/index.tsx
@@ -1,600 +1,210 @@
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 { Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, TouchableOpacity, KeyboardAvoidingView, StyleSheet } from 'react-native';
+import { OmiConnection } from 'friend-lite-react-native';
+import { State as BluetoothState } from 'react-native-ble-plx';
+import { Link } from 'expo-router';
+import { useTheme, ThemeColors } from '@/theme';
// Hooks
-import { useBluetoothManager } from './hooks/useBluetoothManager';
-import { useDeviceScanning } from './hooks/useDeviceScanning';
-import { useDeviceConnection } from './hooks/useDeviceConnection';
-import {
- saveLastConnectedDeviceId,
- getLastConnectedDeviceId,
- saveWebSocketUrl,
- getWebSocketUrl,
- saveUserId,
- getUserId,
- getAuthEmail,
- getJwtToken,
-} from './utils/storage';
-import { useAudioListener } from './hooks/useAudioListener';
-import { useAudioStreamer } from './hooks/useAudioStreamer';
-import { usePhoneAudioRecorder } from './hooks/usePhoneAudioRecorder';
+import { useBluetoothManager } from '@/hooks/useBluetoothManager';
+import { useDeviceScanning } from '@/hooks/useDeviceScanning';
+import { useDeviceConnection } from '@/hooks/useDeviceConnection';
+import { useAppSettings } from '@/hooks/useAppSettings';
+import { useAutoReconnect } from '@/hooks/useAutoReconnect';
+import { useAudioStreamingOrchestrator } from '@/hooks/useAudioStreamingOrchestrator';
+import { useAudioListener } from '@/hooks/useAudioListener';
+import { useAudioStreamer } from '@/hooks/useAudioStreamer';
+import { usePhoneAudioRecorder } from '@/hooks/usePhoneAudioRecorder';
+import { useBatteryMonitor } from '@/hooks/useBatteryMonitor';
+import { saveLastConnectedDeviceId } from '@/utils/storage';
// 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 ObsidianIngest from './components/ObsidianIngest';
-import PhoneAudioButton from './components/PhoneAudioButton';
+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 ObsidianIngest from '@/components/ObsidianIngest';
+import PhoneAudioButton from '@/components/PhoneAudioButton';
export default function App() {
- // Initialize OmiConnection
+ const { colors } = useTheme();
+ const s = createStyles(colors);
const omiConnection = useRef(new OmiConnection()).current;
-
- // Filter state
const [showOnlyOmi, setShowOnlyOmi] = useState(false);
- // State for remembering the last connected device
- const [lastKnownDeviceId, setLastKnownDeviceId] = useState(null);
- const [isAttemptingAutoReconnect, setIsAttemptingAutoReconnect] = useState(false);
- const [triedAutoReconnectForCurrentId, setTriedAutoReconnectForCurrentId] = useState(false);
-
- // State for WebSocket URL for custom audio streaming
- const [webSocketUrl, setWebSocketUrl] = useState('');
-
- // State for User ID
- const [userId, setUserId] = useState('');
-
- // Authentication state
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [currentUserEmail, setCurrentUserEmail] = useState(null);
- const [jwtToken, setJwtToken] = useState(null);
-
- // Bluetooth Management Hook
- const {
- bleManager,
- bluetoothState,
- permissionGranted,
- requestBluetoothPermission,
- isPermissionsLoading,
- } = useBluetoothManager();
+ // Bluetooth
+ const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager();
- // Custom Audio Streamer Hook
+ // Audio
const audioStreamer = useAudioStreamer();
-
- // Phone Audio Recorder Hook
const phoneAudioRecorder = usePhoneAudioRecorder();
- const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false);
-
- const {
- isListeningAudio: isOmiAudioListenerActive,
- audioPacketsReceived,
- startAudioListener: originalStartAudioListener,
- stopAudioListener: originalStopAudioListener,
- isRetrying: isAudioListenerRetrying,
- retryAttempts: audioListenerRetryAttempts,
- } = useAudioListener(
- omiConnection,
- () => !!deviceConnection.connectedDeviceId
- );
+ const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId);
- // Refs to hold the current state for onDeviceDisconnect without causing re-memoization
+ // Refs for disconnect cleanup
const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive);
const isAudioStreamingRef = useRef(audioStreamer.isStreaming);
+ useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]);
+ useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]);
- useEffect(() => {
- isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive;
- }, [isOmiAudioListenerActive]);
-
- useEffect(() => {
- isAudioStreamingRef.current = audioStreamer.isStreaming;
- }, [audioStreamer.isStreaming]);
+ // Settings
+ const settings = useAppSettings();
- // Now define the stable onDeviceConnect and onDeviceDisconnect callbacks
+ // Device callbacks
const onDeviceConnect = useCallback(async () => {
- console.log('[App.tsx] Device connected callback.');
- const deviceIdToSave = omiConnection.connectedDeviceId; // Corrected: Use property from OmiConnection instance
-
+ const deviceIdToSave = omiConnection.connectedDeviceId;
if (deviceIdToSave) {
- console.log('[App.tsx] Saving connected device ID to storage:', deviceIdToSave);
await saveLastConnectedDeviceId(deviceIdToSave);
- setLastKnownDeviceId(deviceIdToSave); // Update state for consistency
- setTriedAutoReconnectForCurrentId(false); // Reset if a new device connects successfully
- } else {
- console.warn('[App.tsx] onDeviceConnect: Could not determine connected device ID to save. omiConnection.connectedDeviceId was null/undefined.');
+ autoReconnect.setLastKnownDeviceId(deviceIdToSave);
+ autoReconnect.setTriedAutoReconnectForCurrentId(false);
}
- // Actions on connect (e.g., auto-fetch codec/battery)
- }, [omiConnection]); // saveLastConnectedDeviceId is stable, omiConnection is stable ref
+ }, [omiConnection]);
const onDeviceDisconnect = useCallback(async () => {
- console.log('[App.tsx] Device disconnected callback.');
- if (isOmiAudioListenerActiveRef.current) {
- console.log('[App.tsx] Disconnect: Stopping audio listener.');
- await originalStopAudioListener();
- }
- if (isAudioStreamingRef.current) {
- console.log('[App.tsx] Disconnect: Stopping custom audio streaming.');
- audioStreamer.stopStreaming();
- }
- // Also stop phone audio if it's running
+ if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener();
+ if (isAudioStreamingRef.current) audioStreamer.stopStreaming();
if (phoneAudioRecorder.isRecording) {
- console.log('[App.tsx] Disconnect: Stopping phone audio recording.');
await phoneAudioRecorder.stopRecording();
- setIsPhoneAudioMode(false);
+ orchestrator.setIsPhoneAudioMode(false);
}
- }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording, setIsPhoneAudioMode]);
-
- // Initialize Device Connection hook, passing the memoized callbacks
- const deviceConnection = useDeviceConnection(
- omiConnection,
- onDeviceDisconnect,
- onDeviceConnect
- );
-
- // Effect to load settings on app startup
- useEffect(() => {
- const loadSettings = async () => {
- const deviceId = await getLastConnectedDeviceId();
- if (deviceId) {
- console.log('[App.tsx] Loaded last known device ID from storage:', deviceId);
- setLastKnownDeviceId(deviceId);
- setTriedAutoReconnectForCurrentId(false);
- } else {
- console.log('[App.tsx] No last known device ID found in storage. Auto-reconnect will not be attempted.');
- setLastKnownDeviceId(null); // Explicitly ensure it's null
- setTriedAutoReconnectForCurrentId(true); // Mark that we shouldn't try (as no ID is known)
- }
+ }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]);
- const storedWsUrl = await getWebSocketUrl();
- if (storedWsUrl) {
- console.log('[App.tsx] Loaded WebSocket URL from storage:', storedWsUrl);
- setWebSocketUrl(storedWsUrl);
- } else {
- // Set default to simple backend
- const defaultUrl = 'ws://localhost:8000/ws';
- console.log('[App.tsx] No stored WebSocket URL, setting default for simple backend:', defaultUrl);
- setWebSocketUrl(defaultUrl);
- await saveWebSocketUrl(defaultUrl);
- }
-
- const storedUserId = await getUserId();
- if (storedUserId) {
- console.log('[App.tsx] Loaded User ID from storage:', storedUserId);
- setUserId(storedUserId);
- }
-
- // Load authentication data
- const storedEmail = await getAuthEmail();
- const storedToken = await getJwtToken();
- if (storedEmail && storedToken) {
- console.log('[App.tsx] Loaded auth data from storage for:', storedEmail);
- setCurrentUserEmail(storedEmail);
- setJwtToken(storedToken);
- setIsAuthenticated(true);
- }
- };
- loadSettings();
- }, []);
+ const deviceConnection = useDeviceConnection(omiConnection, onDeviceDisconnect, onDeviceConnect);
+ // Battery monitor
+ const batteryMonitor = useBatteryMonitor({
+ connectedDeviceId: deviceConnection.connectedDeviceId,
+ getBatteryLevel: deviceConnection.getRawBatteryLevel,
+ onConnectionLost: deviceConnection.disconnectFromDevice,
+ });
- // Device Scanning Hook
- const {
- devices: scannedDevices,
- scanning,
- startScan,
- stopScan: stopDeviceScanAction,
- } = useDeviceScanning(
- bleManager, // From useBluetoothManager
- omiConnection,
- permissionGranted, // From useBluetoothManager
- bluetoothState === BluetoothState.PoweredOn, // Derived from useBluetoothManager
- requestBluetoothPermission // From useBluetoothManager, should be stable
- );
-
- // Effect for attempting auto-reconnection
- useEffect(() => {
- if (
- bluetoothState === BluetoothState.PoweredOn &&
- permissionGranted &&
- lastKnownDeviceId &&
- !deviceConnection.connectedDeviceId && // Only if not already connected
- !deviceConnection.isConnecting && // Only if not currently trying to connect by other means
- !scanning && // Only if not currently scanning
- !isAttemptingAutoReconnect && // Only if not already attempting auto-reconnect
- !triedAutoReconnectForCurrentId // Only try once per loaded/set lastKnownDeviceId
- ) {
- const attemptAutoConnect = async () => {
- console.log(`[App.tsx] Attempting to auto-reconnect to device: ${lastKnownDeviceId}`);
- setIsAttemptingAutoReconnect(true);
- setTriedAutoReconnectForCurrentId(true); // Mark that we've initiated an attempt for this ID
- try {
- // useDeviceConnection.connectToDevice can take a device ID string directly
- await deviceConnection.connectToDevice(lastKnownDeviceId);
- // If connectToDevice throws, catch block handles it.
- // If it resolves, the connection attempt was made.
- // The onDeviceConnect callback will be triggered if successful.
- console.log(`[App.tsx] Auto-reconnect attempt initiated for ${lastKnownDeviceId}. Waiting for connection event.`);
- // Removed the if(success) block as connectToDevice is void
- } catch (error) {
- console.error(`[App.tsx] Error auto-reconnecting to ${lastKnownDeviceId}:`, error);
- // Clear the problematic device ID from storage and state
- if (lastKnownDeviceId) { // Ensure we have an ID to clear
- console.log(`[App.tsx] Clearing problematic device ID ${lastKnownDeviceId} from storage due to auto-reconnect failure.`);
- await saveLastConnectedDeviceId(null); // Clears from AsyncStorage
- setLastKnownDeviceId(null); // Clears from current app state
- }
- } finally {
- setIsAttemptingAutoReconnect(false);
- }
- };
- attemptAutoConnect();
- }
- }, [
+ // Auto-reconnect
+ const autoReconnect = useAutoReconnect({
bluetoothState,
permissionGranted,
- lastKnownDeviceId,
- deviceConnection.connectedDeviceId,
- deviceConnection.isConnecting,
- scanning,
- deviceConnection.connectToDevice, // Stable function from the hook
- triedAutoReconnectForCurrentId,
- isAttemptingAutoReconnect, // Added to prevent re-triggering while one is in progress
- // Added saveLastConnectedDeviceId and setLastKnownDeviceId to dependency array if they were not already implicitly covered
- // saveLastConnectedDeviceId is an import, setLastKnownDeviceId is a state setter - typically stable
- ]);
-
- const handleStartAudioListeningAndStreaming = useCallback(async () => {
- if (!webSocketUrl || webSocketUrl.trim() === '') {
- Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.');
- return;
- }
- if (!omiConnection.isConnected() || !deviceConnection.connectedDeviceId) {
- Alert.alert('Device Not Connected', 'Please connect to an OMI device first.');
- return;
- }
-
- try {
- let finalWebSocketUrl = webSocketUrl.trim();
-
- // Check if this is the advanced backend (requires authentication) or simple backend
- const isAdvancedBackend = jwtToken && isAuthenticated;
-
- if (isAdvancedBackend) {
- // Advanced backend: include JWT token and device parameters
- const params = new URLSearchParams();
- params.append('token', jwtToken);
-
- if (userId && userId.trim() !== '') {
- params.append('device_name', userId.trim());
- console.log('[App.tsx] Using advanced backend with token and device_name:', userId.trim());
- } else {
- params.append('device_name', 'phone'); // Default device name
- console.log('[App.tsx] Using advanced backend with token and default device_name');
- }
-
- const separator = webSocketUrl.includes('?') ? '&' : '?';
- finalWebSocketUrl = `${webSocketUrl}${separator}${params.toString()}`;
- console.log('[App.tsx] Advanced backend WebSocket URL constructed (token hidden for security)');
- } else {
- // Simple backend: use URL as-is without authentication
- console.log('[App.tsx] Using simple backend without authentication:', finalWebSocketUrl);
- }
-
- // Start custom WebSocket streaming first
- await audioStreamer.startStreaming(finalWebSocketUrl);
-
- // Then start OMI audio listener
- await originalStartAudioListener(async (audioBytes) => {
- const wsReadyState = audioStreamer.getWebSocketReadyState();
- if (wsReadyState === WebSocket.OPEN && audioBytes.length > 0) {
- await audioStreamer.sendAudio(audioBytes);
- }
- });
- } catch (error) {
- console.error('[App.tsx] Error starting audio listening/streaming:', error);
- Alert.alert('Error', 'Could not start audio listening or streaming.');
- // Ensure cleanup if one part started but the other failed
- if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
- }
- }, [originalStartAudioListener, audioStreamer, webSocketUrl, userId, omiConnection, deviceConnection.connectedDeviceId, jwtToken, isAuthenticated]);
-
- const handleStopAudioListeningAndStreaming = useCallback(async () => {
- console.log('[App.tsx] Stopping audio listening and streaming.');
- await originalStopAudioListener();
- audioStreamer.stopStreaming();
- }, [originalStopAudioListener, audioStreamer]);
-
- // Phone Audio Streaming Functions
- const handleStartPhoneAudioStreaming = useCallback(async () => {
- if (!webSocketUrl || webSocketUrl.trim() === '') {
- Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.');
- return;
- }
-
- try {
- let finalWebSocketUrl = webSocketUrl.trim();
-
- // Convert HTTP/HTTPS to WS/WSS protocol
- finalWebSocketUrl = finalWebSocketUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
-
- // Ensure /ws endpoint is included
- if (!finalWebSocketUrl.includes('/ws')) {
- // Remove trailing slash if present, then add /ws
- finalWebSocketUrl = finalWebSocketUrl.replace(/\/$/, '') + '/ws';
- }
-
- // Add codec parameter if not present
- if (!finalWebSocketUrl.includes('codec=')) {
- const separator = finalWebSocketUrl.includes('?') ? '&' : '?';
- finalWebSocketUrl = finalWebSocketUrl + separator + 'codec=pcm';
- }
-
- // Check if this is the advanced backend (requires authentication) or simple backend
- const isAdvancedBackend = jwtToken && isAuthenticated;
-
- if (isAdvancedBackend) {
- // Advanced backend: include JWT token and device parameters
- const params = new URLSearchParams();
- params.append('token', jwtToken);
-
- const deviceName = userId && userId.trim() !== '' ? userId.trim() : 'phone-mic';
- params.append('device_name', deviceName);
- console.log('[App.tsx] Using advanced backend with token and device_name:', deviceName);
-
- const separator = finalWebSocketUrl.includes('?') ? '&' : '?';
- finalWebSocketUrl = `${finalWebSocketUrl}${separator}${params.toString()}`;
- console.log('[App.tsx] Advanced backend WebSocket URL constructed for phone audio');
- } else {
- // Simple backend: use URL as-is without authentication
- console.log('[App.tsx] Using simple backend without authentication for phone audio');
- }
-
- // Start WebSocket streaming first
- await audioStreamer.startStreaming(finalWebSocketUrl);
-
- // Start phone audio recording
- await phoneAudioRecorder.startRecording(async (pcmBuffer) => {
- const wsReadyState = audioStreamer.getWebSocketReadyState();
- if (wsReadyState === WebSocket.OPEN && pcmBuffer.length > 0) {
- await audioStreamer.sendAudio(pcmBuffer);
- }
- });
-
- setIsPhoneAudioMode(true);
- console.log('[App.tsx] Phone audio streaming started successfully');
- } catch (error) {
- console.error('[App.tsx] Error starting phone audio streaming:', error);
- Alert.alert('Error', 'Could not start phone audio streaming.');
- // Ensure cleanup if one part started but the other failed
- if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
- if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording();
- setIsPhoneAudioMode(false);
- }
- }, [audioStreamer, phoneAudioRecorder, webSocketUrl, userId, jwtToken, isAuthenticated]);
+ deviceConnection,
+ scanning: false,
+ });
- const handleStopPhoneAudioStreaming = useCallback(async () => {
- console.log('[App.tsx] Stopping phone audio streaming.');
- await phoneAudioRecorder.stopRecording();
- audioStreamer.stopStreaming();
- setIsPhoneAudioMode(false);
- }, [phoneAudioRecorder, audioStreamer]);
+ // Scanning
+ const { devices: scannedDevices, scanning, startScan, stopScan: stopDeviceScanAction } = useDeviceScanning(bleManager, omiConnection, permissionGranted, bluetoothState === BluetoothState.PoweredOn, requestBluetoothPermission);
- const handleTogglePhoneAudio = useCallback(async () => {
- if (isPhoneAudioMode || phoneAudioRecorder.isRecording) {
- await handleStopPhoneAudioStreaming();
- } else {
- await handleStartPhoneAudioStreaming();
- }
- }, [isPhoneAudioMode, phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]);
-
- // Store stable references for cleanup
- const cleanupRefs = useRef({
+ // Audio orchestrator
+ const orchestrator = useAudioStreamingOrchestrator({
omiConnection,
- bleManager,
- disconnectFromDevice: deviceConnection.disconnectFromDevice,
- stopAudioStreaming: audioStreamer.stopStreaming,
- stopPhoneAudio: phoneAudioRecorder.stopRecording,
- });
-
- // Update refs when functions change
- useEffect(() => {
- cleanupRefs.current = {
- omiConnection,
- bleManager,
- disconnectFromDevice: deviceConnection.disconnectFromDevice,
- stopAudioStreaming: audioStreamer.stopStreaming,
- stopPhoneAudio: phoneAudioRecorder.stopRecording,
- };
+ deviceConnection,
+ audioStreamer,
+ phoneAudioRecorder,
+ originalStartAudioListener,
+ originalStopAudioListener,
+ settings,
});
- // Cleanup only on actual unmount (no dependencies to avoid re-runs)
+ // Cleanup
+ const cleanupRefs = useRef({ omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording });
+ useEffect(() => { cleanupRefs.current = { omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }; });
useEffect(() => {
return () => {
- console.log('App unmounting - cleaning up OmiConnection, BleManager, AudioStreamer, and PhoneAudioRecorder');
const refs = cleanupRefs.current;
-
- if (refs.omiConnection.isConnected()) {
- refs.disconnectFromDevice().catch(err => console.error("Error disconnecting in cleanup:", err));
- }
- if (refs.bleManager) {
- refs.bleManager.destroy();
- }
+ if (refs.omiConnection.isConnected()) refs.disconnectFromDevice().catch(() => {});
+ if (refs.bleManager) refs.bleManager.destroy();
refs.stopAudioStreaming();
- // Phone audio stopRecording now handles inactive state gracefully
- refs.stopPhoneAudio().catch(err => console.error("Error stopping phone audio in cleanup:", err));
+ refs.stopPhoneAudio().catch(() => {});
};
- }, []); // Empty dependency array - only run on mount/unmount
+ }, []);
const canScan = React.useMemo(() => (
- permissionGranted &&
- bluetoothState === BluetoothState.PoweredOn &&
- !isAttemptingAutoReconnect &&
- !deviceConnection.isConnecting &&
+ permissionGranted && bluetoothState === BluetoothState.PoweredOn &&
+ !autoReconnect.isAttemptingAutoReconnect && !deviceConnection.isConnecting &&
!deviceConnection.connectedDeviceId &&
- (triedAutoReconnectForCurrentId || !lastKnownDeviceId)
- // Removed authentication requirement for scanning
- ), [
- permissionGranted,
- bluetoothState,
- isAttemptingAutoReconnect,
- deviceConnection.isConnecting,
- deviceConnection.connectedDeviceId,
- triedAutoReconnectForCurrentId,
- lastKnownDeviceId,
- ]);
+ (autoReconnect.triedAutoReconnectForCurrentId || !autoReconnect.lastKnownDeviceId)
+ ), [permissionGranted, bluetoothState, autoReconnect.isAttemptingAutoReconnect, deviceConnection.isConnecting, deviceConnection.connectedDeviceId, autoReconnect.triedAutoReconnectForCurrentId, autoReconnect.lastKnownDeviceId]);
const filteredDevices = React.useMemo(() => {
- if (!showOnlyOmi) {
- return scannedDevices;
- }
- return scannedDevices.filter(device => {
- const name = device.name?.toLowerCase() || '';
+ if (!showOnlyOmi) return scannedDevices;
+ return scannedDevices.filter(d => {
+ const name = d.name?.toLowerCase() || '';
return name.includes('omi') || name.includes('friend');
});
}, [scannedDevices, showOnlyOmi]);
- const handleSetAndSaveWebSocketUrl = useCallback(async (url: string) => {
- setWebSocketUrl(url);
- await saveWebSocketUrl(url);
- }, []);
-
- const handleSetAndSaveUserId = useCallback(async (id: string) => {
- setUserId(id);
- await saveUserId(id || null);
- }, []);
-
- // Authentication status change handler
- const handleAuthStatusChange = useCallback((authenticated: boolean, email: string | null, token: string | null) => {
- setIsAuthenticated(authenticated);
- setCurrentUserEmail(email);
- setJwtToken(token);
- console.log('[App.tsx] Auth status changed:', { authenticated, email: email ? 'logged in' : 'logged out' });
- }, []);
-
- const handleCancelAutoReconnect = useCallback(async () => {
- console.log('[App.tsx] Cancelling auto-reconnection attempt.');
- if (lastKnownDeviceId) {
- // Clear the last known device ID to prevent further auto-reconnect attempts in this session
- await saveLastConnectedDeviceId(null);
- setLastKnownDeviceId(null);
- setTriedAutoReconnectForCurrentId(true); // Mark as tried to prevent immediate re-trigger if conditions meet again
- }
- // Attempt to stop any ongoing connection process
- // disconnectFromDevice also sets isConnecting to false internally.
- await deviceConnection.disconnectFromDevice();
- setIsAttemptingAutoReconnect(false); // Explicitly set to false to hide the auto-reconnect screen
- }, [deviceConnection, lastKnownDeviceId, saveLastConnectedDeviceId, setLastKnownDeviceId, setTriedAutoReconnectForCurrentId, setIsAttemptingAutoReconnect]);
-
+ // Loading / auto-reconnect screens
if (isPermissionsLoading && bluetoothState === BluetoothState.Unknown) {
return (
-
-
-
- {isAttemptingAutoReconnect
- ? `Attempting to reconnect to the last device (${lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})...`
+
+
+
+ {autoReconnect.isAttemptingAutoReconnect
+ ? `Reconnecting to ${autoReconnect.lastKnownDeviceId?.substring(0, 10)}...`
: 'Initializing Bluetooth...'}
);
}
- if (isAttemptingAutoReconnect) {
+ if (autoReconnect.isAttemptingAutoReconnect) {
return (
-
-
-
-
- Attempting to reconnect to the last device ({lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})...
+
+
+
+
+ Reconnecting to {autoReconnect.lastKnownDeviceId?.substring(0, 10)}...
-
+
+ Cancel
+
);
}
return (
-
-
-
- Friend Lite
+
+
+
+
+ Chronicle
+
+
+ Logs
+
+
+
+
+
+
+
+ {settings.isAuthenticated && }
- {/* Backend Connection - moved to top */}
-
-
- {/* Authentication Section */}
-
-
- {/* Obsidian Ingestion - Only when authenticated */}
- {isAuthenticated && (
-
- )}
-
- {/* Phone Audio Streaming Button */}
-
+
+
-
-
- {!isAuthenticated && (
-
-
- π‘ Login is required for advanced backend features. Simple backend can be used without authentication.
-
+ {!settings.isAuthenticated && (
+
+ Login is required for advanced backend features. Simple backend can be used without authentication.
)}
- {scannedDevices.length > 0 && !deviceConnection.connectedDeviceId && !isAttemptingAutoReconnect && (
-
-
- Found Devices
-
- Show only OMI/Friend
+ {scannedDevices.length > 0 && !deviceConnection.connectedDeviceId && !autoReconnect.isAttemptingAutoReconnect && (
+
+
+ Found Devices
+
+ Show only OMI/Friend
@@ -604,87 +214,54 @@ export default function App() {
(
-
+
)}
keyExtractor={(item) => item.id}
style={{ maxHeight: 200 }}
/>
) : (
-
-
- {showOnlyOmi
- ? `No OMI/Friend devices found. ${scannedDevices.length} other device(s) hidden by filter.`
- : 'No devices found.'
- }
+
+
+ {showOnlyOmi ? `No OMI/Friend devices found. ${scannedDevices.length} other device(s) hidden by filter.` : 'No devices found.'}
)}
)}
-
- {deviceConnection.connectedDeviceId && filteredDevices.find(d => d.id === deviceConnection.connectedDeviceId) && (
-
- Connected Device
- d.id === deviceConnection.connectedDeviceId)!}
- onConnect={() => {}}
- onDisconnect={async () => {
- console.log('[App.tsx] Manual disconnect initiated via DeviceListItem.');
- // Prevent auto-reconnection by clearing the last known device ID *before* disconnecting.
- await saveLastConnectedDeviceId(null);
- setLastKnownDeviceId(null);
- setTriedAutoReconnectForCurrentId(true);
-
- // TODO: Consider adding setIsDisconnecting(true) here if a visual indicator is needed
- // and a finally block to set it to false, similar to the old handleDisconnectPress.
- // For now, focusing on the core logic.
- try {
- await deviceConnection.disconnectFromDevice();
- console.log('[App.tsx] Manual disconnect from device successful.');
- } catch (error) {
- console.error('[App.tsx] Error during manual disconnect call:', error);
- Alert.alert('Error', 'Failed to disconnect from the device.');
- }
- }}
- isConnecting={deviceConnection.isConnecting}
- connectedDeviceId={deviceConnection.connectedDeviceId}
- />
-
+ {deviceConnection.connectedDeviceId && filteredDevices.find(d => d.id === deviceConnection.connectedDeviceId) && (
+
+ Connected Device
+ d.id === deviceConnection.connectedDeviceId)!}
+ onConnect={() => {}}
+ onDisconnect={async () => {
+ await saveLastConnectedDeviceId(null);
+ autoReconnect.setLastKnownDeviceId(null);
+ autoReconnect.setTriedAutoReconnectForCurrentId(true);
+ try { await deviceConnection.disconnectFromDevice(); } catch { Alert.alert('Error', 'Failed to disconnect.'); }
+ }}
+ isConnecting={deviceConnection.isConnecting}
+ connectedDeviceId={deviceConnection.connectedDeviceId}
+ />
+
)}
-
- {/* Show disconnect button when connected but scan list isn't visible */}
+
{deviceConnection.connectedDeviceId && !filteredDevices.find(d => d.id === deviceConnection.connectedDeviceId) && (
-
-
-
- Connected to device: {deviceConnection.connectedDeviceId.substring(0, 15)}...
-
+
+
+ Connected to: {deviceConnection.connectedDeviceId.substring(0, 15)}...
{
- console.log('[App.tsx] Manual disconnect initiated via standalone disconnect button.');
await saveLastConnectedDeviceId(null);
- setLastKnownDeviceId(null);
- setTriedAutoReconnectForCurrentId(true);
-
- try {
- await deviceConnection.disconnectFromDevice();
- console.log('[App.tsx] Manual disconnect from device successful.');
- } catch (error) {
- console.error('[App.tsx] Error during manual disconnect call:', error);
- Alert.alert('Error', 'Failed to disconnect from the device.');
- }
+ autoReconnect.setLastKnownDeviceId(null);
+ autoReconnect.setTriedAutoReconnectForCurrentId(true);
+ try { await deviceConnection.disconnectFromDevice(); } catch { Alert.alert('Error', 'Failed to disconnect.'); }
}}
disabled={deviceConnection.isConnecting}
>
- {deviceConnection.isConnecting ? 'Disconnecting...' : 'Disconnect'}
+ {deviceConnection.isConnecting ? 'Disconnecting...' : 'Disconnect'}
@@ -695,19 +272,20 @@ export default function App() {
connectedDeviceId={deviceConnection.connectedDeviceId}
onGetAudioCodec={deviceConnection.getAudioCodec}
currentCodec={deviceConnection.currentCodec}
- onGetBatteryLevel={deviceConnection.getBatteryLevel}
- batteryLevel={deviceConnection.batteryLevel}
+ batteryLevel={batteryMonitor.batteryLevel}
+ isLowBattery={batteryMonitor.isLowBattery}
+ onRefreshBattery={batteryMonitor.refreshBattery}
isListeningAudio={isOmiAudioListenerActive}
- onStartAudioListener={handleStartAudioListeningAndStreaming}
- onStopAudioListener={handleStopAudioListeningAndStreaming}
+ onStartAudioListener={orchestrator.handleStartAudioListeningAndStreaming}
+ onStopAudioListener={orchestrator.handleStopAudioListeningAndStreaming}
audioPacketsReceived={audioPacketsReceived}
- webSocketUrl={webSocketUrl}
- onSetWebSocketUrl={handleSetAndSaveWebSocketUrl}
+ webSocketUrl={settings.webSocketUrl}
+ onSetWebSocketUrl={settings.handleSetAndSaveWebSocketUrl}
isAudioStreaming={audioStreamer.isStreaming}
isConnectingAudioStreamer={audioStreamer.isConnecting}
audioStreamerError={audioStreamer.error}
- userId={userId}
- onSetUserId={handleSetAndSaveUserId}
+ userId={settings.userId}
+ onSetUserId={settings.handleSetAndSaveUserId}
isAudioListenerRetrying={isAudioListenerRetrying}
audioListenerRetryAttempts={audioListenerRetryAttempts}
/>
@@ -718,27 +296,44 @@ export default function App() {
);
}
-const styles = StyleSheet.create({
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
container: {
flex: 1,
- backgroundColor: '#f5f5f5',
+ backgroundColor: colors.background,
},
content: {
padding: 20,
paddingTop: Platform.OS === 'android' ? 30 : 10,
paddingBottom: 50,
},
+ titleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ },
title: {
fontSize: 24,
fontWeight: 'bold',
- marginBottom: 20,
- color: '#333',
- textAlign: 'center',
+ color: colors.text,
+ },
+ diagButton: {
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 6,
+ backgroundColor: colors.inputBackground,
+ borderWidth: 1,
+ borderColor: colors.inputBorder,
+ },
+ diagButtonText: {
+ fontSize: 14,
+ color: colors.textSecondary,
+ fontWeight: '500',
},
section: {
marginBottom: 25,
padding: 15,
- backgroundColor: 'white',
+ backgroundColor: colors.card,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
@@ -759,23 +354,24 @@ const styles = StyleSheet.create({
filterText: {
marginRight: 8,
fontSize: 14,
- color: '#333',
+ color: colors.text,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
- color: '#333',
+ color: colors.text,
},
centeredMessageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
+ backgroundColor: colors.background,
},
centeredMessageText: {
marginTop: 10,
fontSize: 16,
- color: '#555',
+ color: colors.textSecondary,
textAlign: 'center',
},
disconnectContainer: {
@@ -786,20 +382,17 @@ const styles = StyleSheet.create({
},
connectedText: {
fontSize: 14,
- color: '#333',
+ color: colors.text,
flex: 1,
marginRight: 10,
},
button: {
- backgroundColor: '#007AFF',
+ backgroundColor: colors.primary,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
alignItems: 'center',
},
- buttonDanger: {
- backgroundColor: '#FF3B30',
- },
buttonText: {
color: 'white',
fontSize: 14,
@@ -811,21 +404,21 @@ const styles = StyleSheet.create({
},
noDevicesText: {
fontSize: 14,
- color: '#666',
+ color: colors.textTertiary,
textAlign: 'center',
fontStyle: 'italic',
},
authWarning: {
marginBottom: 20,
padding: 15,
- backgroundColor: '#FFF3CD',
+ backgroundColor: colors.inputBackground,
borderRadius: 8,
borderWidth: 1,
- borderColor: '#FFEAA7',
+ borderColor: colors.warning,
},
authWarningText: {
fontSize: 14,
- color: '#856404',
+ color: colors.warning,
textAlign: 'center',
fontWeight: '500',
},
diff --git a/app/package-lock.json b/app/package-lock.json
index be20753f..c76c02ac 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "friend-lite-app",
+ "name": "chronicle-app",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "friend-lite-app",
+ "name": "chronicle-app",
"version": "1.0.0",
"dependencies": {
"@notifee/react-native": "^9.1.8",
@@ -17,6 +17,7 @@
"expo": "~53.0.9",
"expo-build-properties": "~0.14.8",
"expo-dev-client": "~5.2.4",
+ "expo-router": "~5.0.6",
"expo-status-bar": "~2.2.3",
"friend-lite-react-native": "^1.0.2",
"install": "^0.13.0",
@@ -25,6 +26,8 @@
"react-native": "0.79.6",
"react-native-base64": "^0.2.1",
"react-native-ble-plx": "^3.5.0",
+ "react-native-safe-area-context": "5.4.0",
+ "react-native-screens": "~4.11.1",
"setimmediate": "^1.0.5",
"webidl-conversions": "^7.0.0"
},
@@ -77,6 +80,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2017,6 +2021,15 @@
"resolve-from": "^5.0.0"
}
},
+ "node_modules/@expo/metro-runtime": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-5.0.4.tgz",
+ "integrity": "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
"node_modules/@expo/osascript": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.2.5.tgz",
@@ -2284,6 +2297,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 +2762,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",
@@ -2755,6 +2813,7 @@
"integrity": "sha512-Q7UnBqOO/JsWfgmO9qZjrKgMi/0U9ih0FywXXheml8VH1hn/pBXKIeO/BvzA6g5gHIvBZ/6KyhdGoNok1R/ZJw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-native-community/cli-clean": "20.0.1",
"@react-native-community/cli-config": "20.0.1",
@@ -3299,6 +3358,118 @@
}
}
},
+ "node_modules/@react-navigation/bottom-tabs": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.14.0.tgz",
+ "integrity": "sha512-oG2VdoInuIyK0o9o90Yo47hTCS+sPyVE7k8eSB37vt3pq3uQxjh8V3xJpsQfOfNlRUXOPB/ejH93nSBlP7ZHmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.5",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.1.28",
+ "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.14.0",
+ "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz",
+ "integrity": "sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/routers": "^7.5.3",
+ "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.4",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "license": "MIT"
+ },
+ "node_modules/@react-navigation/elements": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz",
+ "integrity": "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g==",
+ "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.28",
+ "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.28",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz",
+ "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@react-navigation/core": "^7.14.0",
+ "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.13.0",
+ "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.13.0.tgz",
+ "integrity": "sha512-5OOp1IKEd5woHl9hGBU0qCAfrQ4+7Tqej0HzDzGQeXzS8tg9gq84x1qUdRvFk5BXbhuAyvJliY9F1/I07d2X0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-navigation/elements": "^2.9.5",
+ "color": "^4.2.3",
+ "sf-symbols-typescript": "^2.1.0",
+ "warn-once": "^0.1.1"
+ },
+ "peerDependencies": {
+ "@react-navigation/native": "^7.1.28",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz",
+ "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==",
+ "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",
@@ -3433,6 +3604,12 @@
"@types/istanbul-lib-report": "*"
}
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
@@ -3448,6 +3625,7 @@
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3572,6 +3750,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3583,6 +3762,35 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ajv-formats": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
+ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ },
+ "peerDependencies": {
+ "ajv": "^8.8.2"
+ }
+ },
"node_modules/anser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
@@ -4126,6 +4334,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4387,6 +4596,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 +4666,19 @@
"node": ">=0.8"
}
},
+ "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 +4697,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 +4932,15 @@
"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/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -5043,6 +5290,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-53.0.22.tgz",
"integrity": "sha512-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "0.24.21",
@@ -5131,6 +5379,7 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.7.tgz",
"integrity": "sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@expo/config": "~11.0.12",
"@expo/env": "~1.0.7"
@@ -5223,6 +5472,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.2.tgz",
"integrity": "sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -5247,6 +5497,247 @@
"react": "*"
}
},
+ "node_modules/expo-linking": {
+ "version": "8.0.11",
+ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
+ "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-constants": "~18.0.12",
+ "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",
+ "dependencies": {
+ "@babel/highlight": "^7.10.4"
+ }
+ },
+ "node_modules/expo-linking/node_modules/@expo/config": {
+ "version": "12.0.13",
+ "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz",
+ "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "~7.10.4",
+ "@expo/config-plugins": "~54.0.4",
+ "@expo/config-types": "^54.0.10",
+ "@expo/json-file": "^10.0.8",
+ "deepmerge": "^4.3.1",
+ "getenv": "^2.0.0",
+ "glob": "^13.0.0",
+ "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.1"
+ }
+ },
+ "node_modules/expo-linking/node_modules/@expo/config-plugins": {
+ "version": "54.0.4",
+ "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz",
+ "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config-types": "^54.0.10",
+ "@expo/json-file": "~10.0.8",
+ "@expo/plist": "^0.4.8",
+ "@expo/sdk-runtime-versions": "^1.0.0",
+ "chalk": "^4.1.2",
+ "debug": "^4.3.5",
+ "getenv": "^2.0.0",
+ "glob": "^13.0.0",
+ "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.10",
+ "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz",
+ "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==",
+ "license": "MIT"
+ },
+ "node_modules/expo-linking/node_modules/@expo/env": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz",
+ "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==",
+ "license": "MIT",
+ "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.8",
+ "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
+ "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "~7.10.4",
+ "json5": "^2.2.3"
+ }
+ },
+ "node_modules/expo-linking/node_modules/@expo/plist": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz",
+ "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.8",
+ "base64-js": "^1.2.3",
+ "xmlbuilder": "^15.1.1"
+ }
+ },
+ "node_modules/expo-linking/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/expo-linking/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/expo-linking/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/expo-linking/node_modules/expo-constants": {
+ "version": "18.0.13",
+ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
+ "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/config": "~12.0.13",
+ "@expo/env": "~2.0.8"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-linking/node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/expo-linking/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/expo-linking/node_modules/minimatch": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/expo-linking/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/expo-linking/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/expo-linking/node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/expo-manifests": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz",
@@ -5297,6 +5788,60 @@
"invariant": "^2.2.4"
}
},
+ "node_modules/expo-router": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.0.7.tgz",
+ "integrity": "sha512-NlEgRXCKtseDuIHBp87UfkvqsuVrc0MYG+zg33dopaN6wik4RkrWWxUYdNPHub0s/7qMye6zZBY4ZCrXwd/xpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/metro-runtime": "5.0.4",
+ "@expo/server": "^0.6.2",
+ "@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",
+ "schema-utils": "^4.0.1",
+ "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",
@@ -5418,6 +5963,23 @@
"bser": "2.1.1"
}
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5430,6 +5992,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",
@@ -7588,10 +8159,10 @@
}
},
"node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "license": "ISC",
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -8074,6 +8645,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -8269,6 +8841,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",
@@ -8344,6 +8934,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8379,6 +8970,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",
@@ -8390,6 +8999,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.79.6.tgz",
"integrity": "sha512-kvIWSmf4QPfY41HC25TR285N7Fv0Pyn3DAEK8qRL9dA35usSaxsJkHfw+VqnonqJjXOaoKCEanwudRAJ60TBGA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.79.6",
@@ -8483,6 +9093,33 @@
"react-native": "*"
}
},
+ "node_modules/react-native-safe-area-context": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz",
+ "integrity": "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==",
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-screens": {
+ "version": "4.11.1",
+ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.11.1.tgz",
+ "integrity": "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "react-freeze": "^1.0.0",
+ "react-native-is-edge-to-edge": "^1.1.7",
+ "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",
@@ -8909,6 +9546,25 @@
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
+ "node_modules/schema-utils": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
+ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.9",
+ "ajv": "^8.9.0",
+ "ajv-formats": "^2.1.1",
+ "ajv-keywords": "^5.1.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -9089,6 +9745,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 +9770,21 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sf-symbols-typescript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz",
+ "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==",
+ "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 +9923,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 +10044,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 +10116,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",
@@ -9804,6 +10514,34 @@
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
"license": "MIT"
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -9875,6 +10613,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10008,6 +10747,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 +10823,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..2d59b699 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
- "name": "friend-lite-app",
+ "name": "chronicle-app",
"version": "1.0.0",
- "main": "node_modules/expo/AppEntry.js",
+ "main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
@@ -16,6 +16,7 @@
"@siteed/expo-audio-studio": "^2.18.1",
"deprecated-react-native-prop-types": "^5.0.0",
"expo": "~53.0.9",
+ "expo-router": "~5.0.6",
"expo-build-properties": "~0.14.8",
"expo-dev-client": "~5.2.4",
"expo-status-bar": "~2.2.3",
@@ -27,7 +28,9 @@
"react-native-base64": "^0.2.1",
"react-native-ble-plx": "^3.5.0",
"setimmediate": "^1.0.5",
- "webidl-conversions": "^7.0.0"
+ "webidl-conversions": "^7.0.0",
+ "react-native-screens": "~4.11.1",
+ "react-native-safe-area-context": "5.4.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
diff --git a/app/plugins/with-ats.js b/app/plugins/with-ats.js
new file mode 100644
index 00000000..cca47fbf
--- /dev/null
+++ b/app/plugins/with-ats.js
@@ -0,0 +1,10 @@
+const { withInfoPlist } = require('@expo/config-plugins');
+
+module.exports = (config) =>
+ withInfoPlist(config, (cfg) => {
+ cfg.modResults.NSAppTransportSecurity = {
+ NSAllowsArbitraryLoads: true,
+ NSAllowsLocalNetworking: true,
+ };
+ return cfg;
+ });
diff --git a/app/app/components/AuthSection.tsx b/app/src/components/AuthSection.tsx
similarity index 72%
rename from app/app/components/AuthSection.tsx
rename to app/src/components/AuthSection.tsx
index e5014854..91c583fb 100644
--- a/app/app/components/AuthSection.tsx
+++ b/app/src/components/AuthSection.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
import { saveAuthEmail, saveAuthPassword, saveJwtToken, getAuthEmail, getAuthPassword, clearAuthData } from '../utils/storage';
+import { useTheme, ThemeColors } from '../theme';
interface AuthSectionProps {
backendUrl: string;
@@ -15,11 +16,12 @@ export const AuthSection: React.FC = ({
currentUserEmail,
onAuthStatusChange,
}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
- // Load saved email and password on component mount
useEffect(() => {
const loadAuthData = async () => {
const savedEmail = await getAuthEmail();
@@ -35,28 +37,22 @@ export const AuthSection: React.FC = ({
Alert.alert('Missing Credentials', 'Please enter both email and password.');
return;
}
-
if (!backendUrl.trim()) {
Alert.alert('Backend URL Required', 'Please enter a backend URL first.');
return;
}
setIsLoggingIn(true);
-
try {
- // Convert WebSocket URL to HTTP URL for authentication
const baseUrl = backendUrl.replace('ws://', 'http://').replace('wss://', 'https://').split('/ws')[0];
const loginUrl = `${baseUrl}/auth/jwt/login`;
-
const formData = new URLSearchParams();
formData.append('username', email.trim());
formData.append('password', password.trim());
const response = await fetch(loginUrl, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- },
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
});
@@ -67,25 +63,14 @@ export const AuthSection: React.FC = ({
const authData = await response.json();
const jwtToken = authData.access_token;
+ if (!jwtToken) throw new Error('No access token received from server');
- if (!jwtToken) {
- throw new Error('No access token received from server');
- }
-
- // Save credentials and token
await saveAuthEmail(email.trim());
await saveAuthPassword(password.trim());
await saveJwtToken(jwtToken);
-
- console.log('[AuthSection] Login successful for user:', email);
onAuthStatusChange(true, email.trim(), jwtToken);
-
} catch (error) {
- console.error('[AuthSection] Login error:', error);
- Alert.alert(
- 'Login Failed',
- error instanceof Error ? error.message : 'An unknown error occurred during login.'
- );
+ Alert.alert('Login Failed', error instanceof Error ? error.message : 'An unknown error occurred during login.');
} finally {
setIsLoggingIn(false);
}
@@ -96,26 +81,24 @@ export const AuthSection: React.FC = ({
await clearAuthData();
setEmail('');
setPassword('');
- console.log('[AuthSection] Logout successful');
onAuthStatusChange(false, null, null);
} catch (error) {
- console.error('[AuthSection] Logout error:', error);
Alert.alert('Logout Error', 'Failed to clear authentication data.');
}
};
if (isAuthenticated && currentUserEmail) {
return (
-
- Authentication
-
- Logged in as: {currentUserEmail}
+
+ Authentication
+
+ Logged in as: {currentUserEmail}
- Logout
+ Logout
@@ -123,14 +106,15 @@ export const AuthSection: React.FC = ({
}
return (
-
- Authentication
- Email:
+
+ Authentication
+ Email:
= ({
autoComplete="email"
/>
- Password:
+ Password:
= ({
/>
{isLoggingIn ? (
-
+
- Logging in...
+ Logging in...
) : (
- Login
+ Login
)}
{!isAuthenticated && (
-
+
Enter your email and password to authenticate with the backend.
)}
@@ -179,11 +164,11 @@ export const AuthSection: React.FC = ({
);
};
-const styles = StyleSheet.create({
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
section: {
marginBottom: 25,
padding: 15,
- backgroundColor: 'white',
+ backgroundColor: colors.card,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
@@ -195,28 +180,28 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
marginBottom: 15,
- color: '#333',
+ color: colors.text,
},
inputLabel: {
fontSize: 14,
- color: '#333',
+ color: colors.text,
marginBottom: 5,
marginTop: 10,
fontWeight: '500',
},
textInput: {
- backgroundColor: '#f0f0f0',
+ backgroundColor: colors.inputBackground,
borderWidth: 1,
- borderColor: '#ddd',
+ borderColor: colors.inputBorder,
borderRadius: 6,
padding: 10,
fontSize: 14,
width: '100%',
marginBottom: 10,
- color: '#333',
+ color: colors.text,
},
button: {
- backgroundColor: '#007AFF',
+ backgroundColor: colors.primary,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
@@ -225,12 +210,9 @@ const styles = StyleSheet.create({
elevation: 2,
},
buttonDisabled: {
- backgroundColor: '#A0A0A0',
+ backgroundColor: colors.disabled,
opacity: 0.7,
},
- buttonDanger: {
- backgroundColor: '#FF3B30',
- },
buttonText: {
color: 'white',
fontSize: 16,
@@ -242,7 +224,7 @@ const styles = StyleSheet.create({
},
helpText: {
fontSize: 12,
- color: '#666',
+ color: colors.textTertiary,
marginTop: 10,
textAlign: 'center',
fontStyle: 'italic',
@@ -254,11 +236,11 @@ const styles = StyleSheet.create({
},
authenticatedText: {
fontSize: 14,
- color: '#4CD964',
+ color: colors.success,
fontWeight: '500',
flex: 1,
marginRight: 10,
},
});
-export default AuthSection;
\ No newline at end of file
+export default AuthSection;
diff --git a/app/src/components/BackendStatus.tsx b/app/src/components/BackendStatus.tsx
new file mode 100644
index 00000000..f69f7a57
--- /dev/null
+++ b/app/src/components/BackendStatus.tsx
@@ -0,0 +1,244 @@
+import React, { useState, useEffect } from 'react';
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native';
+import { useTheme, ThemeColors } from '../theme';
+
+interface BackendStatusProps {
+ backendUrl: string;
+ onBackendUrlChange: (url: string) => void;
+ jwtToken: string | null;
+}
+
+interface HealthStatus {
+ status: 'unknown' | 'checking' | 'healthy' | 'unhealthy' | 'auth_required';
+ message: string;
+ lastChecked?: Date;
+}
+
+export const BackendStatus: React.FC = ({
+ backendUrl,
+ onBackendUrlChange,
+ jwtToken,
+}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+
+ const [healthStatus, setHealthStatus] = useState({
+ status: 'unknown',
+ message: 'Not checked',
+ });
+
+ const checkBackendHealth = async (showAlert: boolean = false) => {
+ if (!backendUrl.trim()) {
+ setHealthStatus({ status: 'unhealthy', message: 'Backend URL not set' });
+ return;
+ }
+
+ setHealthStatus({ status: 'checking', message: 'Checking connection...' });
+
+ try {
+ let baseUrl = backendUrl.trim();
+ if (baseUrl.startsWith('ws://')) baseUrl = baseUrl.replace('ws://', 'http://');
+ else if (baseUrl.startsWith('wss://')) baseUrl = baseUrl.replace('wss://', 'https://');
+ baseUrl = baseUrl.split('/ws')[0];
+
+ const healthUrl = `${baseUrl}/health`;
+ console.log('[BackendStatus] Checking health at:', healthUrl);
+
+ const response = await fetch(healthUrl, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ ...(jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}),
+ },
+ });
+
+ if (response.ok) {
+ const healthData = await response.json();
+ setHealthStatus({ status: 'healthy', message: `Connected (${healthData.status || 'OK'})`, lastChecked: new Date() });
+ if (showAlert) Alert.alert('Connection Success', 'Successfully connected to backend!');
+ } else if (response.status === 401 || response.status === 403) {
+ setHealthStatus({ status: 'auth_required', message: 'Authentication required', lastChecked: new Date() });
+ if (showAlert) Alert.alert('Authentication Required', 'Please login to access the backend.');
+ } else {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.log('[BackendStatus] Health check error:', error);
+ let errorMessage = 'Connection failed';
+ if (error instanceof Error) {
+ console.log('[BackendStatus] Error name:', error.name, 'message:', error.message);
+ if (error.message.includes('Network request failed')) errorMessage = 'Network request failed - check URL and network connection';
+ else if (error.name === 'AbortError') errorMessage = 'Request timeout';
+ else errorMessage = error.message;
+ }
+ setHealthStatus({ status: 'unhealthy', message: errorMessage, lastChecked: new Date() });
+ if (showAlert) {
+ Alert.alert('Connection Failed', `Could not connect to backend: ${errorMessage}\n\nMake sure the backend is running and accessible.`);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (backendUrl.trim()) {
+ const timer = setTimeout(() => { checkBackendHealth(false); }, 500);
+ return () => clearTimeout(timer);
+ }
+ }, [backendUrl, jwtToken]);
+
+ const getStatusColor = (status: HealthStatus['status']): string => {
+ switch (status) {
+ case 'healthy': return colors.success;
+ case 'checking': return colors.warning;
+ case 'unhealthy': return colors.danger;
+ case 'auth_required': return colors.warning;
+ default: return colors.disabled;
+ }
+ };
+
+ return (
+
+ Backend Connection
+
+ Backend URL:
+
+
+
+
+ Status:
+
+
+ {healthStatus.message}
+
+ {healthStatus.status === 'checking' && (
+
+ )}
+
+
+ {healthStatus.lastChecked && (
+ Last checked: {healthStatus.lastChecked.toLocaleTimeString()}
+ )}
+
+
+ checkBackendHealth(true)}
+ disabled={healthStatus.status === 'checking'}
+ >
+ {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'}
+
+
+
+ Enter the WebSocket URL of your backend server. Status is automatically checked.
+
+
+ );
+};
+
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
+ section: {
+ marginBottom: 25,
+ padding: 15,
+ backgroundColor: colors.card,
+ borderRadius: 10,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 3,
+ elevation: 2,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 15,
+ color: colors.text,
+ },
+ inputLabel: {
+ fontSize: 14,
+ color: colors.text,
+ marginBottom: 5,
+ fontWeight: '500',
+ },
+ textInput: {
+ backgroundColor: colors.inputBackground,
+ borderWidth: 1,
+ borderColor: colors.inputBorder,
+ borderRadius: 6,
+ padding: 10,
+ fontSize: 14,
+ width: '100%',
+ marginBottom: 15,
+ color: colors.text,
+ },
+ statusContainer: {
+ marginBottom: 15,
+ padding: 10,
+ backgroundColor: colors.inputBackground,
+ borderRadius: 6,
+ borderWidth: 1,
+ borderColor: colors.inputBorder,
+ },
+ statusRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ statusLabel: {
+ fontSize: 14,
+ fontWeight: '500',
+ color: colors.text,
+ },
+ statusValue: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'flex-end',
+ },
+ statusText: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ lastCheckedText: {
+ fontSize: 12,
+ color: colors.textTertiary,
+ marginTop: 5,
+ textAlign: 'center',
+ fontStyle: 'italic',
+ },
+ button: {
+ backgroundColor: colors.primary,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ alignItems: 'center',
+ marginBottom: 10,
+ elevation: 2,
+ },
+ buttonDisabled: {
+ backgroundColor: colors.disabled,
+ opacity: 0.7,
+ },
+ buttonText: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ helpText: {
+ fontSize: 12,
+ color: colors.textTertiary,
+ textAlign: 'center',
+ fontStyle: 'italic',
+ },
+});
+
+export default BackendStatus;
diff --git a/app/app/components/BluetoothStatusBanner.tsx b/app/src/components/BluetoothStatusBanner.tsx
similarity index 67%
rename from app/app/components/BluetoothStatusBanner.tsx
rename to app/src/components/BluetoothStatusBanner.tsx
index c05675b6..fd088e54 100644
--- a/app/app/components/BluetoothStatusBanner.tsx
+++ b/app/src/components/BluetoothStatusBanner.tsx
@@ -1,6 +1,7 @@
import React from 'react';
-import { View, Text, TouchableOpacity, StyleSheet, Linking, Platform } from 'react-native';
+import { View, Text, TouchableOpacity, StyleSheet, Linking } from 'react-native';
import { State as BluetoothState } from 'react-native-ble-plx';
+import { useTheme, ThemeColors } from '../theme';
interface BluetoothStatusBannerProps {
bluetoothState: BluetoothState;
@@ -9,56 +10,56 @@ interface BluetoothStatusBannerProps {
onRequestPermission: () => void;
}
-export const BluetoothStatusBanner: React.FC = ({
- bluetoothState,
- isPermissionsLoading,
- permissionGranted,
- onRequestPermission
+export const BluetoothStatusBanner: React.FC = ({
+ bluetoothState,
+ isPermissionsLoading,
+ permissionGranted,
+ onRequestPermission
}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+
if (isPermissionsLoading && bluetoothState === BluetoothState.Unknown) {
return (
-
- Initializing Bluetooth...
+
+ Initializing Bluetooth...
);
}
if (bluetoothState === BluetoothState.PoweredOn && permissionGranted) {
- return null; // All good, don't show banner
+ return null;
}
let bannerMessage = 'Bluetooth status is unknown.';
let buttonText = 'Check Status';
let onButtonPress: (() => void) | undefined = undefined;
+ let isWarning = false;
switch (bluetoothState) {
case BluetoothState.PoweredOff:
bannerMessage = 'Bluetooth is turned off. Please enable Bluetooth to use this app.';
buttonText = 'Open Settings';
onButtonPress = () => Linking.openSettings().catch(err => console.warn("Couldn't open settings:", err));
+ isWarning = true;
break;
case BluetoothState.Unauthorized:
bannerMessage = 'Bluetooth permission not granted. Please allow Bluetooth access.';
buttonText = 'Grant Permission';
onButtonPress = onRequestPermission;
+ isWarning = true;
break;
case BluetoothState.Unsupported:
bannerMessage = 'Bluetooth is not supported on this device.';
- buttonText = 'OK';
- onButtonPress = undefined;
break;
case BluetoothState.Resetting:
bannerMessage = 'Bluetooth is resetting. Please wait.';
- buttonText = 'OK';
- onButtonPress = undefined;
break;
case BluetoothState.PoweredOn:
if (!permissionGranted) {
bannerMessage = 'Bluetooth is on, but permission is needed.';
buttonText = 'Grant Permission';
onButtonPress = onRequestPermission;
- } else {
- // This case should be caught by the early return null
}
break;
default:
@@ -69,18 +70,18 @@ export const BluetoothStatusBanner: React.FC = ({
}
return (
-
- {bannerMessage}
+
+ {bannerMessage}
{onButtonPress && (
-
- {buttonText}
+
+ {buttonText}
)}
);
};
-const styles = StyleSheet.create({
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
statusBanner: {
padding: 12,
borderRadius: 8,
@@ -89,12 +90,6 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
},
- bannerWarning: {
- backgroundColor: '#FF9500', // Orange for warnings
- },
- bannerInfo: {
- backgroundColor: '#007AFF', // Blue for info
- },
statusText: {
color: 'white',
fontSize: 14,
@@ -115,5 +110,4 @@ const styles = StyleSheet.create({
},
});
-// Exporting default to be easily consumable if this is the only export
-export default BluetoothStatusBanner;
\ No newline at end of file
+export default BluetoothStatusBanner;
diff --git a/app/app/components/DeviceDetails.tsx b/app/src/components/DeviceDetails.tsx
similarity index 51%
rename from app/app/components/DeviceDetails.tsx
rename to app/src/components/DeviceDetails.tsx
index ebf204c3..4d2c6b5b 100644
--- a/app/app/components/DeviceDetails.tsx
+++ b/app/src/components/DeviceDetails.tsx
@@ -1,35 +1,26 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, TextInput } from 'react-native';
import { BleAudioCodec } from 'friend-lite-react-native';
+import { useTheme, ThemeColors } from '../theme';
interface DeviceDetailsProps {
- // Device Info
connectedDeviceId: string | null;
onGetAudioCodec: () => void;
currentCodec: BleAudioCodec | null;
- onGetBatteryLevel: () => void;
batteryLevel: number;
-
- // Audio Listener
+ isLowBattery: boolean;
+ onRefreshBattery: () => void;
isListeningAudio: boolean;
onStartAudioListener: () => void;
onStopAudioListener: () => void;
audioPacketsReceived: number;
-
- // WebSocket URL for custom backend
webSocketUrl: string;
onSetWebSocketUrl: (url: string) => void;
-
- // Custom Audio Streamer Status
isAudioStreaming: boolean;
isConnectingAudioStreamer: boolean;
audioStreamerError: string | null;
-
- // User ID Management
userId: string;
onSetUserId: (userId: string) => void;
-
- // Audio Listener Retry State
isAudioListenerRetrying?: boolean;
audioListenerRetryAttempts?: number;
}
@@ -38,8 +29,9 @@ export const DeviceDetails: React.FC = ({
connectedDeviceId,
onGetAudioCodec,
currentCodec,
- onGetBatteryLevel,
batteryLevel,
+ isLowBattery,
+ onRefreshBattery,
isListeningAudio,
onStartAudioListener,
onStopAudioListener,
@@ -54,131 +46,133 @@ export const DeviceDetails: React.FC = ({
isAudioListenerRetrying,
audioListenerRetryAttempts
}) => {
- if (!connectedDeviceId) return null;
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+ if (!connectedDeviceId) return null;
return (
-
- Device Functions
+
+ Device Functions
- {/* Audio Codec */}
-
- Get Audio Codec
+
+ Get Audio Codec
{currentCodec && (
-
- Current Audio Codec:
- {currentCodec}
+
+ Current Audio Codec:
+ {currentCodec}
)}
- {/* Battery Level */}
-
- Get Battery Level
-
- {batteryLevel >= 0 && (
-
- Battery Level:
-
-
- {batteryLevel}%
+ {batteryLevel >= 0 ? (
+
+
+ Battery Level:
+
+ Refresh
+
+
+
+
+ {batteryLevel}%
+ {isLowBattery && Low battery}
+
+ ) : (
+
+ Battery: reading...
)}
- {/* User ID Management */}
-
- User ID (optional)
- Enter User ID (for device identification):
+
+ User ID (optional)
+ Enter User ID (for device identification):
-
-
{userId && (
-
- Current User ID:
- {userId}
+
+ Current User ID:
+ {userId}
)}
- {/* Audio Controls */}
-
+
-
- {isListeningAudio ? "Stop Audio Listener" :
+
+ {isListeningAudio ? "Stop Audio Listener" :
isAudioListenerRetrying ? "Stop Retry" : "Start Audio Listener"}
-
+
{isAudioListenerRetrying && (
-
-
- π Retrying audio listener... (Attempt {audioListenerRetryAttempts || 0}/10)
+
+
+ Retrying audio listener... (Attempt {audioListenerRetryAttempts || 0}/10)
)}
-
+
{isListeningAudio && (
-
- Audio Packets Received:
- {audioPacketsReceived}
+
+ Audio Packets Received:
+ {audioPacketsReceived}
)}
- {/* Transcription Controls - Entire section REMOVED and replaced by WebSocket URL input */}
-
- Custom Audio Streaming
- Backend WebSocket URL:
+
+ Custom Audio Streaming
+ Backend WebSocket URL:
- {/* Display Streamer Status */}
{isConnectingAudioStreamer && (
- Connecting to WebSocket...
+ Connecting to WebSocket...
)}
{isAudioStreaming && (
- Streaming audio to WebSocket...
+ Streaming audio to WebSocket...
)}
{audioStreamerError && (
- Error: {audioStreamerError}
+ Error: {audioStreamerError}
)}
-
);
};
-const styles = StyleSheet.create({
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
section: {
marginBottom: 25,
padding: 15,
- backgroundColor: 'white',
+ backgroundColor: colors.card,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
@@ -190,7 +184,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
marginBottom: 15,
- color: '#333',
+ color: colors.text,
},
subSection: {
marginTop: 20,
@@ -199,29 +193,16 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
- color: '#444',
+ color: colors.textSecondary,
},
button: {
- backgroundColor: '#007AFF',
+ backgroundColor: colors.primary,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
alignItems: 'center',
elevation: 2,
},
- buttonWarning: {
- backgroundColor: '#FF9500',
- },
- buttonDisabled: {
- backgroundColor: '#A0A0A0',
- opacity: 0.7,
- },
- buttonSecondary: {
- backgroundColor: '#8E8E93',
- },
- buttonSecondaryText: {
- color: 'white',
- },
buttonText: {
color: 'white',
fontSize: 16,
@@ -230,40 +211,63 @@ const styles = StyleSheet.create({
infoContainerSM: {
marginTop: 10,
padding: 10,
- backgroundColor: '#f0f0f0',
+ backgroundColor: colors.inputBackground,
borderRadius: 8,
alignItems: 'center',
},
infoTitle: {
fontSize: 14,
fontWeight: '500',
- color: '#555',
+ color: colors.textSecondary,
},
infoValue: {
fontSize: 16,
fontWeight: 'bold',
- color: '#007AFF',
+ color: colors.primary,
marginTop: 5,
},
infoValueLg: {
fontSize: 18,
fontWeight: 'bold',
- color: '#FF9500',
+ color: colors.warning,
marginTop: 5,
},
batteryContainer: {
- marginTop: 10,
+ marginTop: 15,
padding: 12,
- backgroundColor: '#f0f0f0',
+ backgroundColor: colors.inputBackground,
borderRadius: 8,
- alignItems: 'center',
borderLeftWidth: 4,
- borderLeftColor: '#4CD964',
+ borderLeftColor: colors.success,
+ },
+ batteryHeaderRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 4,
+ },
+ refreshButton: {
+ paddingVertical: 4,
+ paddingHorizontal: 10,
+ borderRadius: 4,
+ backgroundColor: colors.separator,
+ },
+ refreshButtonText: {
+ fontSize: 12,
+ color: colors.textSecondary,
+ fontWeight: '500',
+ },
+ lowBatteryText: {
+ marginTop: 6,
+ fontSize: 12,
+ color: colors.danger,
+ fontWeight: '600',
+ textAlign: 'center',
},
batteryLevelDisplayContainer: {
width: '100%',
height: 24,
- backgroundColor: '#e0e0e0',
+ backgroundColor: colors.separator,
borderRadius: 12,
marginTop: 8,
overflow: 'hidden',
@@ -271,7 +275,7 @@ const styles = StyleSheet.create({
},
batteryLevelBar: {
height: '100%',
- backgroundColor: '#4CD964',
+ backgroundColor: colors.success,
borderRadius: 12,
position: 'absolute',
left: 0,
@@ -284,60 +288,51 @@ const styles = StyleSheet.create({
lineHeight: 24,
fontSize: 12,
fontWeight: 'bold',
- color: '#333',
+ color: colors.text,
},
- // Transcription Specific Styles - Some can be repurposed or removed
customStreamerSection: {
marginTop: 20,
paddingTop: 15,
borderTopWidth: 1,
- borderTopColor: '#e0e0e0',
- // alignItems: 'center', // No longer centering checkbox etc.
+ borderTopColor: colors.separator,
},
inputLabel: {
fontSize: 14,
- color: '#333',
+ color: colors.text,
marginBottom: 5,
fontWeight: '500',
},
textInput: {
- backgroundColor: '#f0f0f0',
+ backgroundColor: colors.inputBackground,
borderWidth: 1,
- borderColor: '#ddd',
+ borderColor: colors.inputBorder,
borderRadius: 6,
padding: 10,
fontSize: 14,
- width: '100%', // Ensure input takes full width of its container
+ width: '100%',
marginBottom: 10,
- color: '#333',
+ color: colors.text,
},
- statusText: { // New style for status messages
+ statusText: {
marginTop: 8,
fontSize: 13,
- color: '#555',
+ color: colors.textSecondary,
textAlign: 'left',
},
- statusStreaming: {
- color: 'green',
- },
- statusError: {
- color: 'red',
- fontWeight: 'bold',
- },
retryContainer: {
marginTop: 10,
padding: 12,
- backgroundColor: '#FFF3CD',
+ backgroundColor: colors.inputBackground,
borderRadius: 8,
borderLeftWidth: 4,
- borderLeftColor: '#FF9500',
+ borderLeftColor: colors.warning,
},
retryText: {
fontSize: 14,
- color: '#856404',
+ color: colors.warning,
fontWeight: '500',
textAlign: 'center',
},
});
-export default DeviceDetails;
\ No newline at end of file
+export default DeviceDetails;
diff --git a/app/src/components/DeviceListItem.tsx b/app/src/components/DeviceListItem.tsx
new file mode 100644
index 00000000..33409b88
--- /dev/null
+++ b/app/src/components/DeviceListItem.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { OmiDevice } from 'friend-lite-react-native';
+import { useTheme, ThemeColors } from '../theme';
+import SignalStrength from './SignalStrength';
+
+interface DeviceListItemProps {
+ device: OmiDevice;
+ onConnect: (deviceId: string) => void;
+ onDisconnect: () => void;
+ isConnecting: boolean;
+ connectedDeviceId: string | null;
+}
+
+export const DeviceListItem: React.FC = ({
+ device,
+ onConnect,
+ onDisconnect,
+ isConnecting,
+ connectedDeviceId
+}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+ const isThisDeviceConnected = connectedDeviceId === device.id;
+ const isAnotherDeviceConnected = connectedDeviceId !== null && connectedDeviceId !== device.id;
+
+ return (
+
+
+
+ {device.name || 'Unknown Device'}
+
+
+ ID: {device.id}
+ {device.rssi != null && RSSI: {device.rssi} dBm}
+
+ {
+ isThisDeviceConnected ? (
+
+ {isConnecting ? 'Disconnecting...' : 'Disconnect'}
+
+ ) : (
+ onConnect(device.id)}
+ disabled={isConnecting || isAnotherDeviceConnected}
+ >
+ {isConnecting && connectedDeviceId === device.id ? 'Connecting...' : 'Connect'}
+
+ )
+ }
+
+ );
+};
+
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
+ deviceItem: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 5,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.separator,
+ },
+ deviceInfoContainer: {
+ flex: 1,
+ marginRight: 10,
+ },
+ deviceName: {
+ fontSize: 16,
+ fontWeight: '500',
+ color: colors.text,
+ },
+ deviceInfo: {
+ fontSize: 12,
+ color: colors.textSecondary,
+ marginTop: 2,
+ },
+ button: {
+ backgroundColor: colors.primary,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ alignItems: 'center',
+ elevation: 1,
+ },
+ smallButton: {
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ },
+ buttonDisabled: {
+ backgroundColor: colors.disabled,
+ opacity: 0.7,
+ },
+ buttonText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
+
+export default DeviceListItem;
diff --git a/app/src/components/ObsidianIngest.tsx b/app/src/components/ObsidianIngest.tsx
new file mode 100644
index 00000000..038274bd
--- /dev/null
+++ b/app/src/components/ObsidianIngest.tsx
@@ -0,0 +1,140 @@
+import React, { useState } from 'react';
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
+import { useTheme, ThemeColors } from '../theme';
+
+interface ObsidianIngestProps {
+ backendUrl: string;
+ jwtToken: string | null;
+}
+
+export const ObsidianIngest: React.FC = ({
+ backendUrl,
+ jwtToken,
+}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+ const [vaultPath, setVaultPath] = useState('/app/data/obsidian_vault');
+ const [loading, setLoading] = useState(false);
+
+ const handleIngest = async () => {
+ if (!backendUrl) { Alert.alert("Error", "Backend URL not set"); return; }
+ if (!jwtToken) { Alert.alert("Authentication Required", "Please login to ingest Obsidian vault."); return; }
+
+ setLoading(true);
+ try {
+ let baseUrl = backendUrl.trim();
+ if (baseUrl.startsWith('ws://')) baseUrl = baseUrl.replace('ws://', 'http://');
+ else if (baseUrl.startsWith('wss://')) baseUrl = baseUrl.replace('wss://', 'https://');
+ baseUrl = baseUrl.split('/ws')[0];
+
+ const response = await fetch(`${baseUrl}/api/obsidian/ingest`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwtToken}` },
+ body: JSON.stringify({ vault_path: vaultPath })
+ });
+
+ if (response.ok) Alert.alert("Success", "Ingestion started in background.");
+ else {
+ const errorText = await response.text();
+ Alert.alert("Error", `Ingestion failed: ${response.status} - ${errorText}`);
+ }
+ } catch (e) {
+ Alert.alert("Error", `Network request failed: ${e}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Obsidian Ingestion
+
+ Vault Path (Backend Container):
+
+
+
+ {loading ? 'Starting Ingestion...' : 'Ingest to Neo4j'}
+
+
+
+ Enter the absolute path to the Obsidian vault INSIDE the backend container.
+ Ensure the folder is mounted to the container.
+
+
+ );
+};
+
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
+ section: {
+ marginBottom: 25,
+ padding: 15,
+ backgroundColor: colors.card,
+ borderRadius: 10,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.1,
+ shadowRadius: 3,
+ elevation: 2,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 15,
+ color: colors.text,
+ },
+ inputLabel: {
+ fontSize: 14,
+ color: colors.text,
+ marginBottom: 5,
+ fontWeight: '500',
+ },
+ textInput: {
+ backgroundColor: colors.inputBackground,
+ borderWidth: 1,
+ borderColor: colors.inputBorder,
+ borderRadius: 6,
+ padding: 10,
+ fontSize: 14,
+ width: '100%',
+ marginBottom: 15,
+ color: colors.text,
+ },
+ button: {
+ backgroundColor: '#9b59b6',
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ alignItems: 'center',
+ marginBottom: 10,
+ elevation: 2,
+ },
+ buttonDisabled: {
+ backgroundColor: colors.disabled,
+ opacity: 0.7,
+ },
+ buttonText: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ helpText: {
+ fontSize: 12,
+ color: colors.textTertiary,
+ textAlign: 'center',
+ fontStyle: 'italic',
+ },
+});
+
+export default ObsidianIngest;
diff --git a/app/src/components/PhoneAudioButton.tsx b/app/src/components/PhoneAudioButton.tsx
new file mode 100644
index 00000000..b9957f98
--- /dev/null
+++ b/app/src/components/PhoneAudioButton.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import { TouchableOpacity, Text, View, StyleSheet, ActivityIndicator } from 'react-native';
+import { useTheme, ThemeColors } from '../theme';
+
+interface PhoneAudioButtonProps {
+ isRecording: boolean;
+ isInitializing: boolean;
+ isDisabled: boolean;
+ audioLevel: number;
+ error: string | null;
+ onPress: () => void;
+}
+
+const PhoneAudioButton: React.FC = ({
+ isRecording,
+ isInitializing,
+ isDisabled,
+ audioLevel,
+ error,
+ onPress,
+}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+
+ const getButtonStyle = () => {
+ if (isDisabled && !isRecording) return [s.button, { backgroundColor: colors.disabled }];
+ if (isRecording) return [s.button, { backgroundColor: colors.danger }];
+ if (error) return [s.button, { backgroundColor: colors.warning }];
+ return [s.button, { backgroundColor: colors.primary }];
+ };
+
+ const getButtonText = () => {
+ if (isInitializing) return 'Initializing...';
+ if (isRecording) return 'Stop Phone Audio';
+ return 'Stream Phone Audio';
+ };
+
+ return (
+
+
+
+ {isInitializing ? (
+
+ ) : (
+
+ {getButtonText()}
+
+ )}
+
+
+
+ {isRecording && (
+
+
+
+
+ Audio Level
+
+ )}
+
+ {isRecording && (
+ Streaming audio to backend...
+ )}
+
+ {error && !isRecording && (
+ {error}
+ )}
+
+ {isDisabled && !isRecording && (
+ Disconnect Bluetooth device to use phone audio
+ )}
+
+ );
+};
+
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
+ container: {
+ marginVertical: 10,
+ paddingHorizontal: 20,
+ },
+ buttonWrapper: {
+ alignSelf: 'stretch',
+ },
+ button: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ minHeight: 48,
+ },
+ buttonContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ buttonText: {
+ color: '#FFFFFF',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ statusText: {
+ textAlign: 'center',
+ marginTop: 8,
+ fontSize: 12,
+ color: colors.textTertiary,
+ },
+ errorText: {
+ textAlign: 'center',
+ marginTop: 8,
+ fontSize: 12,
+ color: colors.danger,
+ },
+ disabledText: {
+ textAlign: 'center',
+ marginTop: 8,
+ fontSize: 12,
+ color: colors.textTertiary,
+ fontStyle: 'italic',
+ },
+ audioLevelContainer: {
+ marginTop: 12,
+ alignItems: 'center',
+ },
+ audioLevelBackground: {
+ width: '100%',
+ height: 4,
+ backgroundColor: colors.separator,
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ audioLevelBar: {
+ height: '100%',
+ backgroundColor: colors.success,
+ borderRadius: 2,
+ },
+ audioLevelText: {
+ marginTop: 4,
+ fontSize: 10,
+ color: colors.textTertiary,
+ },
+});
+
+export default PhoneAudioButton;
diff --git a/app/app/components/ScanControls.tsx b/app/src/components/ScanControls.tsx
similarity index 63%
rename from app/app/components/ScanControls.tsx
rename to app/src/components/ScanControls.tsx
index 23f87181..ce84a15d 100644
--- a/app/app/components/ScanControls.tsx
+++ b/app/src/components/ScanControls.tsx
@@ -1,11 +1,12 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
+import { useTheme, ThemeColors } from '../theme';
interface ScanControlsProps {
scanning: boolean;
onScanPress: () => void;
onStopScanPress: () => void;
- canScan: boolean; // To disable button if permissions not granted or BT is off
+ canScan: boolean;
}
export const ScanControls: React.FC = ({
@@ -14,29 +15,32 @@ export const ScanControls: React.FC = ({
onStopScanPress,
canScan,
}) => {
+ const { colors } = useTheme();
+ const s = createStyles(colors);
+
return (
-
- Bluetooth Connection
+
+ Bluetooth Connection
- {scanning ? "Stop Scan" : "Scan for Devices"}
+ {scanning ? "Stop Scan" : "Scan for Devices"}
);
};
-const styles = StyleSheet.create({
+const createStyles = (colors: ThemeColors) => StyleSheet.create({
section: {
marginBottom: 25,
padding: 15,
- backgroundColor: 'white',
+ backgroundColor: colors.card,
borderRadius: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
@@ -48,10 +52,10 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
marginBottom: 15,
- color: '#333',
+ color: colors.text,
},
button: {
- backgroundColor: '#007AFF',
+ backgroundColor: colors.primary,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
@@ -62,11 +66,8 @@ const styles = StyleSheet.create({
shadowOpacity: 0.1,
shadowRadius: 2,
},
- buttonWarning: {
- backgroundColor: '#FF9500',
- },
buttonDisabled: {
- backgroundColor: '#A0A0A0',
+ backgroundColor: colors.disabled,
opacity: 0.7,
},
buttonText: {
@@ -76,4 +77,4 @@ const styles = StyleSheet.create({
},
});
-export default ScanControls;
\ No newline at end of file
+export default ScanControls;
diff --git a/app/src/components/SignalStrength.tsx b/app/src/components/SignalStrength.tsx
new file mode 100644
index 00000000..c5169897
--- /dev/null
+++ b/app/src/components/SignalStrength.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { useTheme } from '../theme';
+
+interface SignalStrengthProps {
+ rssi: number | null | undefined;
+}
+
+function getBars(rssi: number | null | undefined): number {
+ if (rssi == null) return 0;
+ if (rssi >= -50) return 4;
+ if (rssi >= -65) return 3;
+ if (rssi >= -80) return 2;
+ if (rssi >= -90) return 1;
+ return 0;
+}
+
+const BAR_HEIGHTS = [6, 10, 14, 18];
+
+const SignalStrength: React.FC = ({ rssi }) => {
+ const { colors } = useTheme();
+ const bars = getBars(rssi);
+
+ return (
+
+ {BAR_HEIGHTS.map((height, i) => (
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ gap: 2,
+ marginLeft: 8,
+ },
+ bar: {
+ width: 4,
+ borderRadius: 1,
+ },
+});
+
+export default SignalStrength;
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/src/contexts/ConnectionLogContext.tsx b/app/src/contexts/ConnectionLogContext.tsx
new file mode 100644
index 00000000..8493cc90
--- /dev/null
+++ b/app/src/contexts/ConnectionLogContext.tsx
@@ -0,0 +1,73 @@
+import React, { createContext, useContext, useCallback, useRef, useState } from 'react';
+
+export interface ConnectionEvent {
+ id: string;
+ timestamp: Date;
+ type: ConnectionEventType;
+ deviceId?: string;
+ deviceName?: string;
+ details?: string;
+ rssi?: number;
+}
+
+export type ConnectionEventType =
+ | 'scan_start'
+ | 'scan_stop'
+ | 'scan_result'
+ | 'connect_start'
+ | 'connect_success'
+ | 'connect_fail'
+ | 'disconnect'
+ | 'battery_read'
+ | 'audio_start'
+ | 'audio_stop'
+ | 'error'
+ | 'health_ping'
+ | 'reconnect_attempt'
+ | 'bt_state_change';
+
+const MAX_EVENTS = 200;
+let eventCounter = 0;
+
+interface ConnectionLogContextValue {
+ events: ConnectionEvent[];
+ addEvent: (type: ConnectionEventType, details?: string, extra?: Partial) => void;
+ clearEvents: () => void;
+}
+
+const ConnectionLogContext = createContext({
+ events: [],
+ addEvent: () => {},
+ clearEvents: () => {},
+});
+
+export const ConnectionLogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [events, setEvents] = useState([]);
+ const eventsRef = useRef([]);
+
+ const addEvent = useCallback((type: ConnectionEventType, details?: string, extra?: Partial) => {
+ const event: ConnectionEvent = {
+ id: `evt-${++eventCounter}`,
+ timestamp: new Date(),
+ type,
+ details,
+ ...extra,
+ };
+
+ eventsRef.current = [event, ...eventsRef.current].slice(0, MAX_EVENTS);
+ setEvents(eventsRef.current);
+ }, []);
+
+ const clearEvents = useCallback(() => {
+ eventsRef.current = [];
+ setEvents([]);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useConnectionLog = () => useContext(ConnectionLogContext);
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/src/hooks/useAppSettings.ts b/app/src/hooks/useAppSettings.ts
new file mode 100644
index 00000000..f0223890
--- /dev/null
+++ b/app/src/hooks/useAppSettings.ts
@@ -0,0 +1,80 @@
+import { useState, useEffect, useCallback } from 'react';
+import {
+ saveWebSocketUrl,
+ getWebSocketUrl,
+ saveUserId,
+ getUserId,
+ getAuthEmail,
+ getJwtToken,
+} from '../utils/storage';
+
+export interface AppSettings {
+ webSocketUrl: string;
+ userId: string;
+ isAuthenticated: boolean;
+ currentUserEmail: string | null;
+ jwtToken: string | null;
+ handleSetAndSaveWebSocketUrl: (url: string) => Promise;
+ handleSetAndSaveUserId: (id: string) => Promise;
+ handleAuthStatusChange: (authenticated: boolean, email: string | null, token: string | null) => void;
+}
+
+export const useAppSettings = (): AppSettings => {
+ const [webSocketUrl, setWebSocketUrl] = useState('');
+ const [userId, setUserId] = useState('');
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [currentUserEmail, setCurrentUserEmail] = useState(null);
+ const [jwtToken, setJwtToken] = useState(null);
+
+ useEffect(() => {
+ const loadSettings = async () => {
+ const storedWsUrl = await getWebSocketUrl();
+ if (storedWsUrl) {
+ setWebSocketUrl(storedWsUrl);
+ } else {
+ const defaultUrl = 'ws://localhost:8000/ws';
+ setWebSocketUrl(defaultUrl);
+ await saveWebSocketUrl(defaultUrl);
+ }
+
+ const storedUserId = await getUserId();
+ if (storedUserId) setUserId(storedUserId);
+
+ const storedEmail = await getAuthEmail();
+ const storedToken = await getJwtToken();
+ if (storedEmail && storedToken) {
+ setCurrentUserEmail(storedEmail);
+ setJwtToken(storedToken);
+ setIsAuthenticated(true);
+ }
+ };
+ loadSettings();
+ }, []);
+
+ const handleSetAndSaveWebSocketUrl = useCallback(async (url: string) => {
+ setWebSocketUrl(url);
+ await saveWebSocketUrl(url);
+ }, []);
+
+ const handleSetAndSaveUserId = useCallback(async (id: string) => {
+ setUserId(id);
+ await saveUserId(id || null);
+ }, []);
+
+ const handleAuthStatusChange = useCallback((authenticated: boolean, email: string | null, token: string | null) => {
+ setIsAuthenticated(authenticated);
+ setCurrentUserEmail(email);
+ setJwtToken(token);
+ }, []);
+
+ return {
+ webSocketUrl,
+ userId,
+ isAuthenticated,
+ currentUserEmail,
+ jwtToken,
+ handleSetAndSaveWebSocketUrl,
+ handleSetAndSaveUserId,
+ handleAuthStatusChange,
+ };
+};
diff --git a/app/app/hooks/useAudioListener.ts b/app/src/hooks/useAudioListener.ts
similarity index 97%
rename from app/app/hooks/useAudioListener.ts
rename to app/src/hooks/useAudioListener.ts
index 391ed125..e1208baa 100644
--- a/app/app/hooks/useAudioListener.ts
+++ b/app/src/hooks/useAudioListener.ts
@@ -1,7 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import { OmiConnection } from 'friend-lite-react-native';
-import { Subscription, ConnectionPriority } from 'react-native-ble-plx'; // OmiConnection might use this type for subscriptions
+import { Subscription, ConnectionPriority } from 'react-native-ble-plx';
+import { useConnectionLog } from '../contexts/ConnectionLogContext';
interface UseAudioListener {
isListeningAudio: boolean;
@@ -20,6 +21,7 @@ export const useAudioListener = (
const [audioPacketsReceived, setAudioPacketsReceived] = useState(0);
const [isRetrying, setIsRetrying] = useState(false);
const [retryAttempts, setRetryAttempts] = useState(0);
+ const { addEvent } = useConnectionLog();
const audioSubscriptionRef = useRef(null);
const uiUpdateIntervalRef = useRef(null);
@@ -59,6 +61,7 @@ export const useAudioListener = (
setIsListeningAudio(false);
localPacketCounterRef.current = 0; // Reset local counter
// setAudioPacketsReceived(0); // Optionally reset global counter on stop, or keep cumulative
+ addEvent('audio_stop', 'Audio listener stopped');
console.log('Audio listener stopped.');
} catch (error) {
console.error('Stop audio listener error:', error);
@@ -107,6 +110,7 @@ export const useAudioListener = (
setIsListeningAudio(true);
setIsRetrying(false);
setRetryAttempts(0);
+ addEvent('audio_start', 'Audio listener started');
console.log('[AudioListener] Audio listener started successfully');
return true;
} else {
diff --git a/app/app/hooks/useAudioStreamer.ts b/app/src/hooks/useAudioStreamer.ts
similarity index 99%
rename from app/app/hooks/useAudioStreamer.ts
rename to app/src/hooks/useAudioStreamer.ts
index 51e9c8ce..cfd86fd7 100644
--- a/app/app/hooks/useAudioStreamer.ts
+++ b/app/src/hooks/useAudioStreamer.ts
@@ -240,7 +240,7 @@ export const useAudioStreamer = (): UseAudioStreamer => {
}
// Ensure Foreground Service is up so the JS VM isnβt killed when backgrounded
- await startForegroundServiceNotification('Streaming active', 'Keeping WebSocket connection alive');
+ await startForegroundServiceNotification('Chronicle - Streaming', 'Keeping WebSocket connection alive');
console.log(`[AudioStreamer] Initializing WebSocket: ${trimmed}`);
if (websocketRef.current) await stopStreaming(); // close any existing
diff --git a/app/src/hooks/useAudioStreamingOrchestrator.ts b/app/src/hooks/useAudioStreamingOrchestrator.ts
new file mode 100644
index 00000000..43e78511
--- /dev/null
+++ b/app/src/hooks/useAudioStreamingOrchestrator.ts
@@ -0,0 +1,158 @@
+import { useState, useCallback } from 'react';
+import { Alert } from 'react-native';
+import { OmiConnection } from 'friend-lite-react-native';
+import { AppSettings } from './useAppSettings';
+
+interface OrchestratorParams {
+ omiConnection: OmiConnection;
+ deviceConnection: {
+ connectedDeviceId: string | null;
+ };
+ audioStreamer: {
+ isStreaming: boolean;
+ startStreaming: (url: string) => Promise;
+ stopStreaming: () => void;
+ sendAudio: (audioBytes: Uint8Array) => void;
+ getWebSocketReadyState: () => number | undefined;
+ };
+ phoneAudioRecorder: {
+ isRecording: boolean;
+ startRecording: (onData: (pcmBuffer: Uint8Array) => Promise) => Promise;
+ stopRecording: () => Promise;
+ };
+ originalStartAudioListener: (onAudioData: (bytes: Uint8Array) => void) => Promise;
+ originalStopAudioListener: () => Promise;
+ settings: AppSettings;
+}
+
+export interface AudioOrchestrator {
+ isPhoneAudioMode: boolean;
+ setIsPhoneAudioMode: (mode: boolean) => void;
+ handleStartAudioListeningAndStreaming: () => Promise;
+ handleStopAudioListeningAndStreaming: () => Promise;
+ handleTogglePhoneAudio: () => Promise;
+}
+
+export const useAudioStreamingOrchestrator = ({
+ omiConnection,
+ deviceConnection,
+ audioStreamer,
+ phoneAudioRecorder,
+ originalStartAudioListener,
+ originalStopAudioListener,
+ settings,
+}: OrchestratorParams): AudioOrchestrator => {
+ const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false);
+
+ const buildWebSocketUrl = useCallback((baseUrl: string): string => {
+ let url = baseUrl.trim();
+ const isAdvanced = settings.jwtToken && settings.isAuthenticated;
+
+ if (isAdvanced) {
+ const params = new URLSearchParams();
+ params.append('token', settings.jwtToken!);
+ const deviceName = settings.userId?.trim() || 'phone';
+ params.append('device_name', deviceName);
+ const separator = url.includes('?') ? '&' : '?';
+ url = `${url}${separator}${params.toString()}`;
+ }
+ return url;
+ }, [settings.jwtToken, settings.isAuthenticated, settings.userId]);
+
+ const buildPhoneWebSocketUrl = useCallback((baseUrl: string): string => {
+ let url = baseUrl.trim();
+ url = url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
+ if (!url.includes('/ws')) url = url.replace(/\/$/, '') + '/ws';
+ if (!url.includes('codec=')) {
+ const sep = url.includes('?') ? '&' : '?';
+ url = url + sep + 'codec=pcm';
+ }
+
+ const isAdvanced = settings.jwtToken && settings.isAuthenticated;
+ if (isAdvanced) {
+ const params = new URLSearchParams();
+ params.append('token', settings.jwtToken!);
+ const deviceName = settings.userId?.trim() || 'phone-mic';
+ params.append('device_name', deviceName);
+ const separator = url.includes('?') ? '&' : '?';
+ url = `${url}${separator}${params.toString()}`;
+ }
+ return url;
+ }, [settings.jwtToken, settings.isAuthenticated, settings.userId]);
+
+ const handleStartAudioListeningAndStreaming = useCallback(async () => {
+ if (!settings.webSocketUrl?.trim()) {
+ Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.');
+ return;
+ }
+ if (!omiConnection.isConnected() || !deviceConnection.connectedDeviceId) {
+ Alert.alert('Device Not Connected', 'Please connect to an OMI device first.');
+ return;
+ }
+
+ try {
+ const finalUrl = buildWebSocketUrl(settings.webSocketUrl);
+ await audioStreamer.startStreaming(finalUrl);
+ await originalStartAudioListener(async (audioBytes) => {
+ const wsReady = audioStreamer.getWebSocketReadyState();
+ if (wsReady === WebSocket.OPEN && audioBytes.length > 0) {
+ await audioStreamer.sendAudio(audioBytes);
+ }
+ });
+ } catch (error) {
+ Alert.alert('Error', 'Could not start audio listening or streaming.');
+ if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
+ }
+ }, [originalStartAudioListener, audioStreamer, settings.webSocketUrl, omiConnection, deviceConnection.connectedDeviceId, buildWebSocketUrl]);
+
+ const handleStopAudioListeningAndStreaming = useCallback(async () => {
+ await originalStopAudioListener();
+ audioStreamer.stopStreaming();
+ }, [originalStopAudioListener, audioStreamer]);
+
+ const handleStartPhoneAudioStreaming = useCallback(async () => {
+ if (!settings.webSocketUrl?.trim()) {
+ Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.');
+ return;
+ }
+
+ try {
+ const finalUrl = buildPhoneWebSocketUrl(settings.webSocketUrl);
+ await audioStreamer.startStreaming(finalUrl);
+ await phoneAudioRecorder.startRecording(async (pcmBuffer) => {
+ const wsReady = audioStreamer.getWebSocketReadyState();
+ if (wsReady === WebSocket.OPEN && pcmBuffer.length > 0) {
+ await audioStreamer.sendAudio(pcmBuffer);
+ }
+ });
+ setIsPhoneAudioMode(true);
+ } catch (error) {
+ Alert.alert('Error', 'Could not start phone audio streaming.');
+ if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
+ if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording();
+ setIsPhoneAudioMode(false);
+ }
+ }, [audioStreamer, phoneAudioRecorder, settings.webSocketUrl, buildPhoneWebSocketUrl]);
+
+ const handleStopPhoneAudioStreaming = useCallback(async () => {
+ await phoneAudioRecorder.stopRecording();
+ audioStreamer.stopStreaming();
+ setIsPhoneAudioMode(false);
+ }, [phoneAudioRecorder, audioStreamer]);
+
+ const handleTogglePhoneAudio = useCallback(async () => {
+ if (isPhoneAudioMode || phoneAudioRecorder.isRecording) {
+ await handleStopPhoneAudioStreaming();
+ } else {
+ await handleStartPhoneAudioStreaming();
+ }
+ }, [isPhoneAudioMode, phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]);
+
+ return {
+ isPhoneAudioMode,
+ setIsPhoneAudioMode,
+ handleStartAudioListeningAndStreaming,
+ handleStopAudioListeningAndStreaming,
+ handleTogglePhoneAudio,
+ };
+};
diff --git a/app/src/hooks/useAutoReconnect.ts b/app/src/hooks/useAutoReconnect.ts
new file mode 100644
index 00000000..c2581b99
--- /dev/null
+++ b/app/src/hooks/useAutoReconnect.ts
@@ -0,0 +1,106 @@
+import { useState, useEffect, useCallback } from 'react';
+import { State as BluetoothState } from 'react-native-ble-plx';
+import { saveLastConnectedDeviceId, getLastConnectedDeviceId } from '../utils/storage';
+import { useConnectionLog } from '../contexts/ConnectionLogContext';
+
+interface UseAutoReconnectParams {
+ bluetoothState: BluetoothState;
+ permissionGranted: boolean;
+ deviceConnection: {
+ connectedDeviceId: string | null;
+ isConnecting: boolean;
+ connectToDevice: (deviceId: string) => Promise;
+ disconnectFromDevice: () => Promise;
+ };
+ scanning: boolean;
+}
+
+export interface AutoReconnectState {
+ lastKnownDeviceId: string | null;
+ isAttemptingAutoReconnect: boolean;
+ triedAutoReconnectForCurrentId: boolean;
+ setLastKnownDeviceId: (id: string | null) => void;
+ setTriedAutoReconnectForCurrentId: (tried: boolean) => void;
+ handleCancelAutoReconnect: () => Promise;
+}
+
+export const useAutoReconnect = ({
+ bluetoothState,
+ permissionGranted,
+ deviceConnection,
+ scanning,
+}: UseAutoReconnectParams): AutoReconnectState => {
+ const [lastKnownDeviceId, setLastKnownDeviceId] = useState(null);
+ const [isAttemptingAutoReconnect, setIsAttemptingAutoReconnect] = useState(false);
+ const [triedAutoReconnectForCurrentId, setTriedAutoReconnectForCurrentId] = useState(false);
+ const { addEvent } = useConnectionLog();
+
+ // Load last device on mount
+ useEffect(() => {
+ const load = async () => {
+ const deviceId = await getLastConnectedDeviceId();
+ if (deviceId) {
+ setLastKnownDeviceId(deviceId);
+ setTriedAutoReconnectForCurrentId(false);
+ } else {
+ setLastKnownDeviceId(null);
+ setTriedAutoReconnectForCurrentId(true);
+ }
+ };
+ load();
+ }, []);
+
+ // Auto-reconnect effect
+ useEffect(() => {
+ if (
+ bluetoothState === BluetoothState.PoweredOn &&
+ permissionGranted &&
+ lastKnownDeviceId &&
+ !deviceConnection.connectedDeviceId &&
+ !deviceConnection.isConnecting &&
+ !scanning &&
+ !isAttemptingAutoReconnect &&
+ !triedAutoReconnectForCurrentId
+ ) {
+ const attemptAutoConnect = async () => {
+ setIsAttemptingAutoReconnect(true);
+ setTriedAutoReconnectForCurrentId(true);
+ addEvent('reconnect_attempt', `Auto-reconnecting to ${lastKnownDeviceId}`, { deviceId: lastKnownDeviceId });
+ try {
+ await deviceConnection.connectToDevice(lastKnownDeviceId);
+ } catch (error) {
+ console.error(`[AutoReconnect] Error reconnecting to ${lastKnownDeviceId}:`, error);
+ await saveLastConnectedDeviceId(null);
+ setLastKnownDeviceId(null);
+ } finally {
+ setIsAttemptingAutoReconnect(false);
+ }
+ };
+ attemptAutoConnect();
+ }
+ }, [
+ bluetoothState, permissionGranted, lastKnownDeviceId,
+ deviceConnection.connectedDeviceId, deviceConnection.isConnecting,
+ scanning, deviceConnection.connectToDevice,
+ triedAutoReconnectForCurrentId, isAttemptingAutoReconnect,
+ ]);
+
+ const handleCancelAutoReconnect = useCallback(async () => {
+ if (lastKnownDeviceId) {
+ await saveLastConnectedDeviceId(null);
+ setLastKnownDeviceId(null);
+ setTriedAutoReconnectForCurrentId(true);
+ }
+ await deviceConnection.disconnectFromDevice();
+ setIsAttemptingAutoReconnect(false);
+ }, [deviceConnection, lastKnownDeviceId]);
+
+ return {
+ lastKnownDeviceId,
+ isAttemptingAutoReconnect,
+ triedAutoReconnectForCurrentId,
+ setLastKnownDeviceId,
+ setTriedAutoReconnectForCurrentId,
+ handleCancelAutoReconnect,
+ };
+};
diff --git a/app/src/hooks/useBatteryMonitor.ts b/app/src/hooks/useBatteryMonitor.ts
new file mode 100644
index 00000000..d23e8b5f
--- /dev/null
+++ b/app/src/hooks/useBatteryMonitor.ts
@@ -0,0 +1,110 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useConnectionLog } from '../contexts/ConnectionLogContext';
+
+interface UseBatteryMonitorParams {
+ connectedDeviceId: string | null;
+ getBatteryLevel: () => Promise;
+ onConnectionLost?: () => void;
+}
+
+interface UseBatteryMonitor {
+ batteryLevel: number;
+ isLowBattery: boolean;
+ refreshBattery: () => Promise;
+}
+
+const POLL_INTERVAL_MS = 60_000; // 60 seconds
+const MIN_CHANGE = 5; // Minimum % change to update UI
+const MAX_UI_STALE_MS = 15 * 60_000; // 15 minutes
+const LOW_BATTERY_THRESHOLD = 20;
+const MAX_CONSECUTIVE_FAILURES = 2;
+
+export const useBatteryMonitor = ({
+ connectedDeviceId,
+ getBatteryLevel,
+ onConnectionLost,
+}: UseBatteryMonitorParams): UseBatteryMonitor => {
+ const [batteryLevel, setBatteryLevel] = useState(-1);
+ const [isLowBattery, setIsLowBattery] = useState(false);
+ const { addEvent } = useConnectionLog();
+
+ const lastDisplayedRef = useRef(-1);
+ const lastUpdateTimeRef = useRef(0);
+ const consecutiveFailuresRef = useRef(0);
+ const intervalRef = useRef(null);
+
+ const shouldUpdate = useCallback((newLevel: number): boolean => {
+ const current = lastDisplayedRef.current;
+ if (current === -1) return true; // First read
+ if (Math.abs(newLevel - current) >= MIN_CHANGE) return true;
+ if (Date.now() - lastUpdateTimeRef.current >= MAX_UI_STALE_MS) return true;
+ // Crosses 20% threshold
+ if ((current > LOW_BATTERY_THRESHOLD && newLevel <= LOW_BATTERY_THRESHOLD) ||
+ (current <= LOW_BATTERY_THRESHOLD && newLevel > LOW_BATTERY_THRESHOLD)) return true;
+ return false;
+ }, []);
+
+ const poll = useCallback(async () => {
+ if (!connectedDeviceId) return;
+
+ try {
+ const level = await getBatteryLevel();
+ consecutiveFailuresRef.current = 0;
+
+ if (shouldUpdate(level)) {
+ setBatteryLevel(level);
+ lastDisplayedRef.current = level;
+ lastUpdateTimeRef.current = Date.now();
+ setIsLowBattery(level <= LOW_BATTERY_THRESHOLD);
+ addEvent('battery_read', `Battery: ${level}%`);
+ }
+ } catch (error) {
+ consecutiveFailuresRef.current++;
+ addEvent('health_ping', `Battery read failed (${consecutiveFailuresRef.current}/${MAX_CONSECUTIVE_FAILURES})`);
+
+ if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
+ addEvent('error', 'Connection presumed lost after consecutive battery read failures');
+ onConnectionLost?.();
+ }
+ }
+ }, [connectedDeviceId, getBatteryLevel, shouldUpdate, addEvent, onConnectionLost]);
+
+ const refreshBattery = useCallback(async () => {
+ if (!connectedDeviceId) return;
+ try {
+ const level = await getBatteryLevel();
+ consecutiveFailuresRef.current = 0;
+ setBatteryLevel(level);
+ lastDisplayedRef.current = level;
+ lastUpdateTimeRef.current = Date.now();
+ setIsLowBattery(level <= LOW_BATTERY_THRESHOLD);
+ addEvent('battery_read', `Battery: ${level}% (manual refresh)`);
+ } catch (error) {
+ addEvent('error', `Battery refresh failed: ${error}`);
+ }
+ }, [connectedDeviceId, getBatteryLevel, addEvent]);
+
+ useEffect(() => {
+ if (connectedDeviceId) {
+ consecutiveFailuresRef.current = 0;
+ lastDisplayedRef.current = -1;
+ lastUpdateTimeRef.current = 0;
+ // Initial read after short delay for connection to stabilize
+ const initTimer = setTimeout(poll, 2000);
+ intervalRef.current = setInterval(poll, POLL_INTERVAL_MS);
+ return () => {
+ clearTimeout(initTimer);
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ } else {
+ setBatteryLevel(-1);
+ setIsLowBattery(false);
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ }
+ }, [connectedDeviceId, poll]);
+
+ return { batteryLevel, isLowBattery, refreshBattery };
+};
diff --git a/app/app/hooks/useBluetoothManager.ts b/app/src/hooks/useBluetoothManager.ts
similarity index 96%
rename from app/app/hooks/useBluetoothManager.ts
rename to app/src/hooks/useBluetoothManager.ts
index 4fba29dc..f2f4aba2 100644
--- a/app/app/hooks/useBluetoothManager.ts
+++ b/app/src/hooks/useBluetoothManager.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform, PermissionsAndroid, Permission as ReactNativePermission } from 'react-native';
import { BleManager, State as BluetoothState } from 'react-native-ble-plx';
+import { useConnectionLog } from '../contexts/ConnectionLogContext';
// Define a constant for the minimum Android SDK version requiring runtime permissions
// const MIN_ANDROID_SDK_FOR_PERMISSIONS = 31; // Android 12 (S) - Will use Platform.Version
@@ -10,13 +11,14 @@ export const useBluetoothManager = () => {
const [bluetoothState, setBluetoothState] = useState(BluetoothState.Unknown);
const [permissionGranted, setPermissionGranted] = useState(false);
const [isPermissionsLoading, setIsPermissionsLoading] = useState(true);
+ const { addEvent } = useConnectionLog();
useEffect(() => {
console.log('[BTManager] Initializing Bluetooth Manager');
const subscription = bleManager.onStateChange((state) => {
console.log(`[BTManager] Bluetooth state changed: ${state}`);
setBluetoothState(state);
- // No automatic permission re-check here, handled by initial check and explicit calls
+ addEvent('bt_state_change', `Bluetooth state: ${state}`);
}, true);
return () => {
console.log('[BTManager] Cleaning up Bluetooth Manager state change subscription');
diff --git a/app/app/hooks/useDeviceConnection.ts b/app/src/hooks/useDeviceConnection.ts
similarity index 73%
rename from app/app/hooks/useDeviceConnection.ts
rename to app/src/hooks/useDeviceConnection.ts
index e729169e..96468dac 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 { useConnectionLog } from '../contexts/ConnectionLogContext';
interface UseDeviceConnection {
connectedDevice: OmiDevice | null;
@@ -11,6 +12,7 @@ interface UseDeviceConnection {
disconnectFromDevice: () => Promise;
getAudioCodec: () => Promise;
getBatteryLevel: () => Promise;
+ getRawBatteryLevel: () => Promise;
connectedDeviceId: string | null;
}
@@ -24,6 +26,11 @@ export const useDeviceConnection = (
const [currentCodec, setCurrentCodec] = useState(null);
const [batteryLevel, setBatteryLevel] = useState(-1);
const [connectedDeviceId, setConnectedDeviceId] = useState(null);
+ const { addEvent } = useConnectionLog();
+
+ // Debounce guards
+ const lastConnectAttemptRef = useRef(0);
+ const disconnectTimerRef = useRef(null);
const handleConnectionStateChange = useCallback((id: string, state: string) => {
console.log(`Device ${id} connection state: ${state}`);
@@ -31,20 +38,38 @@ export const useDeviceConnection = (
setIsConnecting(false);
if (isNowConnected) {
+ // Cancel any pending disconnect timer (handles BLE flapping)
+ if (disconnectTimerRef.current) {
+ clearTimeout(disconnectTimerRef.current);
+ disconnectTimerRef.current = null;
+ }
setConnectedDeviceId(id);
- // 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.
+ addEvent('connect_success', `Connected to ${id}`, { deviceId: id });
if (onConnect) onConnect();
} else {
- setConnectedDeviceId(null);
- setConnectedDevice(null);
- setCurrentCodec(null);
- setBatteryLevel(-1);
- if (onDisconnect) onDisconnect();
+ // Debounce disconnect by 500ms to handle BLE flapping
+ if (disconnectTimerRef.current) clearTimeout(disconnectTimerRef.current);
+ disconnectTimerRef.current = setTimeout(() => {
+ disconnectTimerRef.current = null;
+ setConnectedDeviceId(null);
+ setConnectedDevice(null);
+ setCurrentCodec(null);
+ setBatteryLevel(-1);
+ addEvent('disconnect', `Disconnected from ${id}`, { deviceId: id });
+ if (onDisconnect) onDisconnect();
+ }, 500);
}
}, [onDisconnect, onConnect]);
const connectToDevice = useCallback(async (deviceId: string) => {
+ // Connect debounce: ignore rapid double-taps within 100ms
+ const now = Date.now();
+ if (now - lastConnectAttemptRef.current < 100) {
+ console.log('[Connection] Debounced rapid connect attempt');
+ return;
+ }
+ lastConnectAttemptRef.current = now;
+
if (connectedDeviceId && connectedDeviceId !== deviceId) {
console.log('Disconnecting from previous device before connecting to new one.');
await disconnectFromDevice();
@@ -55,17 +80,18 @@ export const useDeviceConnection = (
}
setIsConnecting(true);
- setConnectedDevice(null); // Clear previous device details
+ setConnectedDevice(null);
setCurrentCodec(null);
setBatteryLevel(-1);
+ addEvent('connect_start', `Connecting to ${deviceId}`, { deviceId });
try {
const success = await omiConnection.connect(deviceId, handleConnectionStateChange);
if (success) {
console.log('Successfully initiated connection to device:', deviceId);
- // Note: actual connected state is set by handleConnectionStateChange callback
} else {
setIsConnecting(false);
+ addEvent('connect_fail', `Connection failed to ${deviceId}`, { deviceId });
Alert.alert('Connection Failed', 'Could not connect to the device. Please try again.');
}
} catch (error) {
@@ -73,6 +99,7 @@ export const useDeviceConnection = (
setIsConnecting(false);
setConnectedDevice(null);
setConnectedDeviceId(null);
+ addEvent('connect_fail', `Connection error: ${error}`, { deviceId });
Alert.alert('Connection Error', String(error));
}
}, [omiConnection, handleConnectionStateChange, connectedDeviceId]); // Added connectedDeviceId
@@ -144,6 +171,12 @@ export const useDeviceConnection = (
}
}, [omiConnection, connectedDeviceId]);
+ const getRawBatteryLevel = useCallback(async (): Promise => {
+ const level = await omiConnection.getBatteryLevel();
+ setBatteryLevel(level);
+ return level;
+ }, [omiConnection]);
+
return {
connectedDevice,
isConnecting,
@@ -153,6 +186,7 @@ export const useDeviceConnection = (
disconnectFromDevice,
getAudioCodec,
getBatteryLevel,
+ getRawBatteryLevel,
connectedDeviceId
};
};
\ No newline at end of file
diff --git a/app/app/hooks/useDeviceScanning.ts b/app/src/hooks/useDeviceScanning.ts
similarity index 87%
rename from app/app/hooks/useDeviceScanning.ts
rename to app/src/hooks/useDeviceScanning.ts
index d7780266..9380036c 100644
--- a/app/app/hooks/useDeviceScanning.ts
+++ b/app/src/hooks/useDeviceScanning.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { BleManager, State as BluetoothState } from 'react-native-ble-plx';
-import { OmiConnection, OmiDevice } from 'friend-lite-react-native'; // Assuming this is the correct import for Omi types
+import { OmiConnection, OmiDevice } from 'friend-lite-react-native';
+import { useConnectionLog } from '../contexts/ConnectionLogContext';
interface UseDeviceScanning {
devices: OmiDevice[];
@@ -20,8 +21,9 @@ export const useDeviceScanning = (
const [devices, setDevices] = useState([]);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState(null);
- const stopScanFunctionRef = useRef<(() => void) | null>(null); // To store the stop function from omiConnection.scanForDevices
- const scanTimeoutRef = useRef(null); // For an explicit scan timeout
+ const stopScanFunctionRef = useRef<(() => void) | null>(null);
+ const scanTimeoutRef = useRef(null);
+ const { addEvent } = useConnectionLog();
const handleStopScan = useCallback(() => {
console.log('[Scanner] handleStopScan called');
@@ -39,7 +41,8 @@ export const useDeviceScanning = (
}
stopScanFunctionRef.current = null; // Clear it after stopping
}
- setScanning(false); // Explicitly set scanning to false
+ setScanning(false);
+ addEvent('scan_stop', 'BLE scan stopped');
console.log('[Scanner] Scan stopped, scanning state set to false.');
}, []);
@@ -87,22 +90,22 @@ export const useDeviceScanning = (
console.log('[Scanner] Starting device scan with omiConnection');
setScanning(true);
+ addEvent('scan_start', 'BLE scan started');
try {
stopScanFunctionRef.current = omiConnection.scanForDevices(
(device: OmiDevice) => { // Single callback for found devices
console.log(`[Scanner] Device found: ${device.name} (${device.id}), RSSI: ${device.rssi}`);
setDevices((prevDevices) => {
- // Check if device already exists, update if new, or if RSSI is stronger (optional)
const existingDeviceIndex = prevDevices.findIndex((d) => d.id === device.id);
if (existingDeviceIndex === -1) {
+ addEvent('scan_result', `Found: ${device.name || 'Unknown'}`, { deviceId: device.id, deviceName: device.name || undefined, rssi: device.rssi ?? undefined });
return [...prevDevices, device];
} else {
- // Optionally update existing device info, e.g., if RSSI is part of OmiDevice and useful
- // const updatedDevices = [...prevDevices];
- // updatedDevices[existingDeviceIndex] = device;
- // return updatedDevices;
- return prevDevices; // Or just keep the first instance found
+ // Update existing device with fresh RSSI
+ const updatedDevices = [...prevDevices];
+ updatedDevices[existingDeviceIndex] = device;
+ return updatedDevices;
}
});
}
diff --git a/app/app/hooks/usePhoneAudioRecorder.ts b/app/src/hooks/usePhoneAudioRecorder.ts
similarity index 97%
rename from app/app/hooks/usePhoneAudioRecorder.ts
rename to app/src/hooks/usePhoneAudioRecorder.ts
index 9d422c38..d80fbabb 100644
--- a/app/app/hooks/usePhoneAudioRecorder.ts
+++ b/app/src/hooks/usePhoneAudioRecorder.ts
@@ -8,6 +8,7 @@ import {
ExpoAudioStreamModule,
} from '@siteed/expo-audio-studio';
import type { AudioDataEvent } from '@siteed/expo-audio-studio';
+// @ts-ignore - no type declarations available
import base64 from 'react-native-base64';
@@ -140,8 +141,8 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
// EXACT config from 2025 guide + processing for audio levels
const config = {
interval: 100,
- sampleRate: 16000,
- channels: 1,
+ sampleRate: 16000 as const,
+ channels: 1 as const,
encoding: "pcm_16bit" as const,
enableProcessing: true, // Enable audio analysis for live RMS
intervalAnalysis: 500, // Analysis every 500ms
@@ -149,7 +150,7 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
// EXACT payload handling from guide
const payload = typeof event.data === "string"
? event.data
- : Buffer.from(event.data as ArrayBuffer).toString("base64");
+ : Buffer.from(event.data as unknown as ArrayBuffer).toString("base64");
// Convert to our expected format
if (onAudioDataRef.current && mountedRef.current) {
diff --git a/app/src/theme.ts b/app/src/theme.ts
new file mode 100644
index 00000000..0697c432
--- /dev/null
+++ b/app/src/theme.ts
@@ -0,0 +1,58 @@
+import { useColorScheme } from 'react-native';
+
+export interface ThemeColors {
+ background: string;
+ card: string;
+ cardBorder: string;
+ text: string;
+ textSecondary: string;
+ textTertiary: string;
+ primary: string;
+ success: string;
+ warning: string;
+ danger: string;
+ inputBackground: string;
+ inputBorder: string;
+ separator: string;
+ disabled: string;
+}
+
+const lightColors: ThemeColors = {
+ background: '#f5f5f5',
+ card: '#ffffff',
+ cardBorder: '#e0e0e0',
+ text: '#333333',
+ textSecondary: '#555555',
+ textTertiary: '#888888',
+ primary: '#007AFF',
+ success: '#34C759',
+ warning: '#FF9500',
+ danger: '#FF3B30',
+ inputBackground: '#f0f0f0',
+ inputBorder: '#dddddd',
+ separator: '#e0e0e0',
+ disabled: '#A0A0A0',
+};
+
+const darkColors: ThemeColors = {
+ background: '#000000',
+ card: '#1c1c1e',
+ cardBorder: '#38383a',
+ text: '#f2f2f7',
+ textSecondary: '#aeaeb2',
+ textTertiary: '#636366',
+ primary: '#0a84ff',
+ success: '#30d158',
+ warning: '#ff9f0a',
+ danger: '#ff453a',
+ inputBackground: '#2c2c2e',
+ inputBorder: '#38383a',
+ separator: '#38383a',
+ disabled: '#636366',
+};
+
+export function useTheme() {
+ const scheme = useColorScheme();
+ const isDark = scheme === 'dark';
+ return { colors: isDark ? darkColors : lightColors, isDark };
+}
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
diff --git a/app/tsconfig.json b/app/tsconfig.json
index 4f034926..53e32962 100644
--- a/app/tsconfig.json
+++ b/app/tsconfig.json
@@ -2,6 +2,10 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
- "jsx": "react-native"
+ "jsx": "react-native",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
}
}