Add question types, nudge feature, and share modal

- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
This commit is contained in:
jjbrunton committed 2026-01-25 19:17:39 +00:00
1 parent 8612b16b93
commit 1daf386765
114 files changed
+14877 -7911

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import { Audio } from 'expo-av';
export interface UseAudioRecorderOptions {
/** Maximum recording duration in seconds. Default: 60 */
maxDurationSeconds?: number;
/** Callback when recording completes (either manually or via auto-stop) */
onRecordingComplete?: (uri: string, durationSeconds: number) => void;
}
export type RecordingState = 'idle' | 'recording' | 'stopped';
const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComplete'>> = {
maxDurationSeconds: 60,
};
/**
* Hook for audio recording functionality using expo-av.
* Handles microphone permissions, recording lifecycle, and cleanup.
*
* @param options - Optional configuration for recording
* @returns Audio recorder state and controls
*/
export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
const settings = {
...DEFAULT_OPTIONS,
...options,
};
const [state, setState] = useState<RecordingState>('idle');
const [durationSeconds, setDurationSeconds] = useState(0);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
// Refs to track recording instance and cleanup
const recordingRef = useRef<Audio.Recording | null>(null);
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
// Keep the callback ref up to date
useEffect(() => {
onRecordingCompleteRef.current = options?.onRecordingComplete;
}, [options?.onRecordingComplete]);
/**
* Request microphone permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Microphone Permission',
'Please allow microphone access to record audio.'
);
return false;
}
return true;
}, []);
/**
* Stop the current recording and return the result.
* @returns Object with uri and duration, or null if no recording
*/
const stopRecording = useCallback(async (): Promise<{ uri: string; duration: number } | null> => {
const recording = recordingRef.current;
if (!recording) {
return null;
}
try {
// Get final status before stopping
const status = await recording.getStatusAsync();
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
// Stop and unload the recording
await recording.stopAndUnloadAsync();
// Reset audio mode to allow playback
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recording.getURI();
recordingRef.current = null;
if (uri) {
setRecordingUri(uri);
setState('stopped');
setDurationSeconds(finalDurationSeconds);
// Call the completion callback if provided
if (onRecordingCompleteRef.current) {
onRecordingCompleteRef.current(uri, finalDurationSeconds);
}
return { uri, duration: finalDurationSeconds };
}
setState('idle');
return null;
} catch (error) {
console.error('Error stopping recording:', error);
recordingRef.current = null;
setState('idle');
return null;
}
}, []);
/**
* Start a new audio recording.
* @returns true if recording started successfully, false otherwise
*/
const startRecording = useCallback(async (): Promise<boolean> => {
// Don't start if already recording
if (recordingRef.current) {
return false;
}
try {
// Request permission
const hasPermission = await requestPermission();
if (!hasPermission) {
return false;
}
// Configure audio mode for recording
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
// Reset state
setDurationSeconds(0);
setRecordingUri(null);
setState('recording');
// Create and start the recording
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
// Set up status update callback to track duration
recording.setOnRecordingStatusUpdate((status) => {
if (status.isRecording) {
const currentSeconds = Math.round(status.durationMillis / 1000);
setDurationSeconds(currentSeconds);
// Auto-stop if max duration reached
if (currentSeconds >= settings.maxDurationSeconds) {
stopRecording();
}
}
});
// Set progress update interval (100ms for smooth duration updates)
recording.setProgressUpdateInterval(100);
return true;
} catch (error) {
console.error('Error starting recording:', error);
recordingRef.current = null;
setState('idle');
// Reset audio mode on error
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
} catch {
// Ignore errors during cleanup
}
return false;
}
}, [requestPermission, settings.maxDurationSeconds, stopRecording]);
/**
* Reset the recording state to idle.
* Cleans up any existing recording.
*/
const resetRecording = useCallback(() => {
// Clean up any existing recording
if (recordingRef.current) {
try {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
} catch {
// Ignore errors during cleanup
}
recordingRef.current = null;
}
setState('idle');
setDurationSeconds(0);
setRecordingUri(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingRef.current) {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
recordingRef.current = null;
}
// Reset audio mode
Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
}).catch(() => {
// Ignore errors during cleanup
});
};
}, []);
return {
/** Current recording state: 'idle', 'recording', or 'stopped' */
state,
/** Current recording duration in seconds */
durationSeconds,
/** URI of the recorded audio file (available when state is 'stopped') */
recordingUri,
/** Start a new recording. Returns true if successful. */
startRecording,
/** Stop the current recording. Returns the uri and duration, or null. */
stopRecording,
/** Reset state to idle and clean up any recording. */
resetRecording,
/** Request microphone permission. Returns true if granted. */
requestPermission,
};
};