Apk compilation

This commit is contained in:
2026-06-18 14:09:54 +02:00
parent 9a23863320
commit 5b9216e764
47 changed files with 4573 additions and 620 deletions

View File

@@ -32,6 +32,7 @@
},
"plugins": [
"expo-router",
"@react-native-voice/voice",
[
"expo-av",
{

View File

@@ -1,4 +1,4 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import Voice from "@react-native-voice/voice";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import Svg, { Path } from "react-native-svg";
@@ -6,6 +6,7 @@ import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
NativeModules,
Platform,
Pressable,
ScrollView,
@@ -26,41 +27,22 @@ function formatDuration(durationMs: number) {
.padStart(2, "0")}`;
}
function buildMimeType(uri: string) {
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
switch (extension) {
case "wav":
return "audio/wav";
case "caf":
return "audio/x-caf";
case "webm":
return "audio/webm";
case "mp3":
return "audio/mpeg";
default:
return "audio/m4a";
}
}
function buildFileExtension(uri: string) {
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
}
export default function RecorderScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [locale, setLocale] = useState<Locale>("ca");
const [strings, setStrings] = useState(() => getStrings("ca"));
const [recording, setRecording] = useState<Audio.Recording | null>(null);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
const [recordingMs, setRecordingMs] = useState(0);
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const [responsePreview, setResponsePreview] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isHolding, setIsHolding] = useState(false);
const recordingRef = useRef<Audio.Recording | null>(null);
const [listeningMs, setListeningMs] = useState(0);
const startRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const listeningActiveRef = useRef(false);
const refreshSettings = useCallback(() => {
let isMounted = true;
@@ -75,7 +57,6 @@ export default function RecorderScreen() {
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLocale(settings.language);
setStrings(getStrings(settings.language));
} catch {
@@ -95,222 +76,187 @@ export default function RecorderScreen() {
useFocusEffect(refreshSettings);
useEffect(() => {
if (!recording) {
if (Platform.OS === "web" || !NativeModules.Voice) {
return;
}
const interval = setInterval(() => {
void recording.getStatusAsync().then((status) => {
if (typeof status.durationMillis === "number") {
setRecordingMs(status.durationMillis ?? 0);
}
});
}, 250);
Voice.onSpeechStart = (e) => {
console.log("Voice.onSpeechStart", e);
setTranscript("");
setInterimTranscript("");
setStatusMessage(strings.recording);
startRef.current = Date.now();
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current;
setListeningMs(elapsed);
}, 250);
};
Voice.onSpeechEnd = (e) => {
console.log("Voice.onSpeechEnd", e);
listeningActiveRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
Voice.onSpeechResults = (e) => {
console.log("Voice.onSpeechResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setTranscript(last);
}
};
Voice.onSpeechPartialResults = (e) => {
console.log("Voice.onSpeechPartialResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setInterimTranscript(last || "");
}
};
Voice.onSpeechError = (e) => {
console.log("Voice.onSpeechError", e);
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
setStatusMessage(message);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setListeningMs(0);
listeningActiveRef.current = false;
Alert.alert(strings.recordingFailedTitle, message);
};
return () => {
clearInterval(interval);
Voice.removeAllListeners();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [recording]);
}, []);
useEffect(() => {
return () => {
if (recording) {
void recording.stopAndUnloadAsync().catch(() => undefined);
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
Voice.stop().catch(() => undefined);
listeningActiveRef.current = false;
}
};
}, [recording]);
}, []);
async function startRecording() {
async function startListening() {
try {
setResponsePreview("");
setRecordingUri(null);
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
setStatusMessage(strings.micPermissionDenied);
Alert.alert(
strings.micAccessRequiredTitle,
strings.micAccessRequiredMsg,
);
setTranscript("");
setInterimTranscript("");
const localeCode =
locale.includes("ca")
? "ca-ES"
: locale.includes("es")
? "es-ES"
: "en-US";
setIsHolding(true);
if (Platform.OS === "web") {
console.log("Voice not available on web");
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
setIsHolding(false);
return;
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: false,
});
const result = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
recordingRef.current = result.recording;
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage(strings.recording);
if (!NativeModules.Voice) {
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
setIsHolding(false);
return;
}
await Voice.start(localeCode);
listeningActiveRef.current = true;
} catch (error) {
setStatusMessage(strings.couldNotStartRecording);
Alert.alert(
strings.recordingFailedTitle,
error instanceof Error ? error.message : "",
);
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
console.error("Voice.start failed:", error);
Alert.alert(strings.recordingFailedTitle, msg);
}
}
async function stopRecordingAndUpload() {
if (!recordingRef.current) {
async function stopListeningAndSend() {
setIsHolding(false);
const wasListening = listeningActiveRef.current;
if (!wasListening) {
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
return;
}
listeningActiveRef.current = false;
if (Platform.OS === "web") {
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
if (finalText) {
await sendCommand(finalText);
} else {
setStatusMessage(strings.readyToRecord);
}
return;
}
try {
const activeRecording = recordingRef.current;
const currentStatus = await activeRecording.getStatusAsync();
const durationMillis = currentStatus.durationMillis ?? 0;
await Voice.stop();
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
const uri = activeRecording.getURI();
recordingRef.current = null;
setRecording(null);
setRecordingMs(durationMillis);
if (!uri) {
if (!finalText) {
setStatusMessage(strings.readyToRecord);
return;
}
setRecordingUri(uri);
setStatusMessage(strings.finishedUpload);
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (uploadUrl) {
try {
const mimeType = buildMimeType(uri);
const extension = buildFileExtension(uri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: uri,
} as never);
const headers: Record<string, string> = {};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(uploadUrl, {
method: "POST",
headers,
body: formData,
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
if (!response.ok) {
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage(strings.uploadFailed);
Alert.alert(
strings.uploadFailed,
error instanceof Error ? error.message : "",
);
}
} else {
setStatusMessage(strings.noBackendUrl);
}
await sendCommand(finalText);
} catch (error) {
recordingRef.current = null;
setRecording(null);
setStatusMessage(strings.stopFailedTitle);
Alert.alert(
strings.stopFailedTitle,
error instanceof Error ? error.message : "",
);
listeningActiveRef.current = false;
const msg = error instanceof Error ? error.message : "Stop failed";
console.error("Voice.stop failed:", error);
setStatusMessage(msg);
}
}
async function handlePressIn() {
if (isUploading) return;
setIsHolding(true);
await startRecording();
}
async function sendCommand(text: string) {
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
const commandUrl = trimmedUrl.endsWith("/commands")
? `${trimmedUrl}/text`
: `${trimmedUrl}/commands/text`;
async function handlePressOut() {
if (!isHolding) return;
setIsHolding(false);
await stopRecordingAndUpload();
}
async function uploadRecording(uriOverride?: string) {
const targetUri = uriOverride ?? recordingUri;
if (!targetUri) {
return;
}
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (!uploadUrl) {
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
if (!commandUrl) {
setStatusMessage(strings.noBackendUrl);
return;
}
try {
setIsUploading(true);
setIsSending(true);
setStatusMessage(strings.uploadingRecording);
setResponsePreview("");
const mimeType = buildMimeType(targetUri);
const extension = buildFileExtension(targetUri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: targetUri,
} as never);
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(uploadUrl, {
const response = await fetch(commandUrl, {
method: "POST",
headers,
body: formData,
body: JSON.stringify({ text }),
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
setTranscript("");
setInterimTranscript("");
if (!response.ok) {
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage(strings.uploadComplete);
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage(strings.uploadFailed);
Alert.alert(
@@ -318,17 +264,28 @@ export default function RecorderScreen() {
error instanceof Error ? error.message : "",
);
} finally {
setIsUploading(false);
setIsSending(false);
}
}
async function handlePressIn() {
if (isSending) return;
await startListening();
}
async function handlePressOut() {
await stopListeningAndSend();
}
const releaseLabel = t("releaseToStop", locale);
const holdLabel = t("holdToRecord", locale);
const openSettingsLabel = t("openSettingsHint", locale);
const openSettingsHint = t("openSettingsHint", locale);
const appTitleLabel = t("appTitle", locale);
const recorderTitleLabel = t("recorderTitle", locale);
const serverResponseLabel = t("serverResponse", locale);
const displayText = interimTranscript || transcript;
return (
<View style={styles.safeArea}>
<KeyboardAvoidingView
@@ -367,20 +324,20 @@ export default function RecorderScreen() {
<View style={styles.panel}>
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
{formatDuration(recordingMs)}
{formatDuration(listeningMs)}
</Text>
<Pressable
disabled={isUploading}
disabled={isSending}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.micButton,
isHolding ? styles.holdingButton : styles.idleButton,
isUploading && styles.buttonDisabled,
isSending && styles.buttonDisabled,
]}
>
{isUploading ? (
{isSending ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
@@ -406,21 +363,23 @@ export default function RecorderScreen() {
)}
</Pressable>
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
<Text style={styles.statusText}>
{statusMessage || strings.readyToRecord}
</Text>
<Text style={styles.helperText}>
{isHolding
? releaseLabel
: backendUrl.trim()
? holdLabel
: openSettingsLabel}
: openSettingsHint}
</Text>
{responsePreview ? (
<View style={styles.responseBox}>
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
{displayText && (
<View style={styles.transcriptBox}>
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
<Text style={styles.transcriptText}>{displayText}</Text>
</View>
) : null}
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
@@ -511,14 +470,6 @@ const styles = StyleSheet.create({
backgroundColor: "#d04f2d",
transform: [{ scale: 1.08 }],
},
micButtonText: {
color: "#fff6f3",
fontSize: 20,
fontWeight: "800",
},
recordingLabel: {
fontSize: 18,
},
buttonDisabled: {
opacity: 0.45,
},
@@ -534,6 +485,28 @@ const styles = StyleSheet.create({
lineHeight: 18,
textAlign: "center",
},
transcriptBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
maxWidth: "100%",
},
transcriptLabel: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
transcriptText: {
color: "#36475a",
fontSize: 16,
lineHeight: 22,
textAlign: "center",
fontWeight: "500",
},
responseBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,

223
apk/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-voice/voice": "^3.2.4",
"expo": "~54.0.33",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",
@@ -2784,6 +2785,162 @@
"react-native": "*"
}
},
"node_modules/@react-native-voice/voice": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@react-native-voice/voice/-/voice-3.2.4.tgz",
"integrity": "sha512-4i3IpB/W5VxCI7BQZO5Nr2VB0ecx0SLvkln2Gy29cAQKqgBl+1ZsCwUBChwHlPbmja6vA3tp/+2ADQGwB1OhHg==",
"deprecated": "This package is deprecated. Use expo-speech-recognition instead.",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^2.0.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react-native": ">= 0.60.2"
}
},
"node_modules/@react-native-voice/voice/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/@react-native-voice/voice/node_modules/@expo/config-plugins": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-2.0.4.tgz",
"integrity": "sha512-JGt/X2tFr7H8KBQrKfbGo9hmCubQraMxq5sj3bqDdKmDOLcE1a/EDCP9g0U4GHsa425J8VDIkQUHYz3h3ndEXQ==",
"license": "MIT",
"dependencies": {
"@expo/config-types": "^41.0.0",
"@expo/json-file": "8.2.30",
"@expo/plist": "0.0.13",
"debug": "^4.3.1",
"find-up": "~5.0.0",
"fs-extra": "9.0.0",
"getenv": "^1.0.0",
"glob": "7.1.6",
"resolve-from": "^5.0.0",
"slash": "^3.0.0",
"xcode": "^3.0.1",
"xml2js": "^0.4.23"
}
},
"node_modules/@react-native-voice/voice/node_modules/@expo/config-types": {
"version": "41.0.0",
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-41.0.0.tgz",
"integrity": "sha512-Ax0pHuY5OQaSrzplOkT9DdpdmNzaVDnq9VySb4Ujq7UJ4U4jriLy8u93W98zunOXpcu0iiKubPsqD6lCiq0pig==",
"license": "MIT"
},
"node_modules/@react-native-voice/voice/node_modules/@expo/json-file": {
"version": "8.2.30",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.2.30.tgz",
"integrity": "sha512-vrgGyPEXBoFI5NY70IegusCSoSVIFV3T3ry4tjJg1MFQKTUlR7E0r+8g8XR6qC705rc2PawaZQjqXMAVtV6s2A==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
"fs-extra": "9.0.0",
"json5": "^1.0.1",
"write-file-atomic": "^2.3.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/@expo/plist": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.13.tgz",
"integrity": "sha512-zGPSq9OrCn7lWvwLLHLpHUUq2E40KptUFXn53xyZXPViI0k9lbApcR9KlonQZ95C+ELsf0BQ3gRficwK92Ivcw==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.2.3",
"xmlbuilder": "^14.0.0",
"xmldom": "~0.5.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/getenv": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz",
"integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@react-native-voice/voice/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@react-native-voice/voice/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/@react-native-voice/voice/node_modules/write-file-atomic": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
"integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"signal-exit": "^3.0.2"
}
},
"node_modules/@react-native-voice/voice/node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/xmlbuilder": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz",
"integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -4252,6 +4409,15 @@
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT"
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
"license": "ISC",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -6906,7 +7072,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
@@ -6986,6 +7151,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz",
"integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==",
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^1.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -8363,6 +8543,27 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonfile/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8714,7 +8915,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
@@ -9855,7 +10055,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
@@ -12135,6 +12334,15 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz",
"integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -12802,6 +13010,15 @@
"node": ">=8.0"
}
},
"node_modules/xmldom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz",
"integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-voice/voice": "^3.2.4",
"expo": "~54.0.33",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",