TTs whisper
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { loadRecorderSettings } from "@/lib/recorder-settings";
|
||||
import { getStrings, type Locale, t } from "@/lib/translations";
|
||||
|
||||
function formatDuration(durationMs: number) {
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
@@ -50,14 +51,16 @@ 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 [statusMessage, setStatusMessage] = useState(
|
||||
"Ready to record and send audio.",
|
||||
);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [responsePreview, setResponsePreview] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isHolding, setIsHolding] = useState(false);
|
||||
const recordingRef = useRef<Audio.Recording | null>(null);
|
||||
|
||||
const refreshSettings = useCallback(() => {
|
||||
let isMounted = true;
|
||||
@@ -73,9 +76,11 @@ export default function RecorderScreen() {
|
||||
setBackendUrl(settings.backendUrl);
|
||||
setAuthToken(settings.authToken);
|
||||
setFieldName(settings.fieldName);
|
||||
setLocale(settings.language);
|
||||
setStrings(getStrings(settings.language));
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setStatusMessage("Could not load saved backend settings.");
|
||||
setStatusMessage(strings.loadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,10 +128,10 @@ export default function RecorderScreen() {
|
||||
const permission = await Audio.requestPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
setStatusMessage("Microphone permission was denied.");
|
||||
setStatusMessage(strings.micPermissionDenied);
|
||||
Alert.alert(
|
||||
"Microphone access required",
|
||||
"Enable microphone access to record audio.",
|
||||
strings.micAccessRequiredTitle,
|
||||
strings.micAccessRequiredMsg,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -144,26 +149,28 @@ export default function RecorderScreen() {
|
||||
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||
);
|
||||
|
||||
recordingRef.current = result.recording;
|
||||
setRecording(result.recording);
|
||||
setRecordingMs(0);
|
||||
setStatusMessage("Recording in progress.");
|
||||
setStatusMessage(strings.recording);
|
||||
} catch (error) {
|
||||
setStatusMessage("Recording could not be started.");
|
||||
setStatusMessage(strings.couldNotStartRecording);
|
||||
Alert.alert(
|
||||
"Recording failed",
|
||||
error instanceof Error ? error.message : "Unknown recording error.",
|
||||
strings.recordingFailedTitle,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
if (!recording) {
|
||||
async function stopRecordingAndUpload() {
|
||||
if (!recordingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeRecording = recording;
|
||||
const activeRecording = recordingRef.current;
|
||||
const currentStatus = await activeRecording.getStatusAsync();
|
||||
const durationMillis = currentStatus.durationMillis ?? 0;
|
||||
|
||||
await activeRecording.stopAndUnloadAsync();
|
||||
await Audio.setAudioModeAsync({
|
||||
@@ -172,25 +179,86 @@ export default function RecorderScreen() {
|
||||
});
|
||||
|
||||
const uri = activeRecording.getURI();
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
|
||||
setRecordingUri(uri);
|
||||
setStatusMessage(
|
||||
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
|
||||
);
|
||||
setRecordingMs(durationMillis);
|
||||
|
||||
if (uri && backendUrl.trim()) {
|
||||
await uploadRecording(uri);
|
||||
if (!uri) {
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage("Recording could not be stopped cleanly.");
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setStatusMessage(strings.stopFailedTitle);
|
||||
Alert.alert(
|
||||
"Stop failed",
|
||||
error instanceof Error ? error.message : "Unknown stop error.",
|
||||
strings.stopFailedTitle,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePressIn() {
|
||||
if (isUploading) return;
|
||||
setIsHolding(true);
|
||||
await startRecording();
|
||||
}
|
||||
|
||||
async function handlePressOut() {
|
||||
if (!isHolding) return;
|
||||
setIsHolding(false);
|
||||
await stopRecordingAndUpload();
|
||||
}
|
||||
|
||||
async function uploadRecording(uriOverride?: string) {
|
||||
const targetUri = uriOverride ?? recordingUri;
|
||||
|
||||
@@ -198,16 +266,19 @@ export default function RecorderScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedUrl = backendUrl.trim();
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||
? trimmedUrl
|
||||
: `${trimmedUrl}/audio/upload`;
|
||||
|
||||
if (!trimmedUrl) {
|
||||
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
|
||||
if (!uploadUrl) {
|
||||
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setStatusMessage("Uploading recording.");
|
||||
setStatusMessage(strings.uploadingRecording);
|
||||
setResponsePreview("");
|
||||
|
||||
const mimeType = buildMimeType(targetUri);
|
||||
@@ -226,7 +297,7 @@ export default function RecorderScreen() {
|
||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(trimmedUrl, {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
@@ -236,23 +307,30 @@ export default function RecorderScreen() {
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with ${response.status}. ${responseText}`);
|
||||
throw new Error(`${response.status}. ${responseText}`);
|
||||
}
|
||||
|
||||
setStatusMessage("Upload complete.");
|
||||
setStatusMessage(strings.uploadComplete);
|
||||
} catch (error) {
|
||||
setStatusMessage("Upload failed.");
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
Alert.alert(
|
||||
"Upload failed",
|
||||
error instanceof Error ? error.message : "Unknown upload error.",
|
||||
strings.uploadFailed,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const releaseLabel = t("releaseToStop", locale);
|
||||
const holdLabel = t("holdToRecord", locale);
|
||||
const openSettingsLabel = t("openSettingsHint", locale);
|
||||
const appTitleLabel = t("appTitle", locale);
|
||||
const recorderTitleLabel = t("recorderTitle", locale);
|
||||
const serverResponseLabel = t("serverResponse", locale);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
@@ -265,59 +343,88 @@ export default function RecorderScreen() {
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View style={styles.heroBadge}>
|
||||
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
|
||||
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
|
||||
<Text style={styles.settingsLinkText}>Settings</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings")}
|
||||
hitSlop={10}
|
||||
style={styles.settingsCog}
|
||||
>
|
||||
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
||||
fill="#d3deea"
|
||||
/>
|
||||
<Path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
|
||||
stroke="#d3deea"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>
|
||||
Record a voice message and send it to your backend.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.meterValueCentered}>
|
||||
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||
{formatDuration(recordingMs)}
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
disabled={isUploading}
|
||||
onPress={recording ? stopRecording : startRecording}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[
|
||||
styles.micButton,
|
||||
recording ? styles.stopButton : styles.recordButton,
|
||||
isHolding ? styles.holdingButton : styles.idleButton,
|
||||
isUploading && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color="#fff6f3" size="large" />
|
||||
) : (
|
||||
<Text style={styles.micButtonText}>
|
||||
{recording ? "Stop" : "Record"}
|
||||
</Text>
|
||||
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
|
||||
fill="#fff6f3"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<Path
|
||||
d="M19 10v1a7 7 0 0 1-14 0v-1"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M12 18v3"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
|
||||
<Text style={styles.helperText}>
|
||||
{backendUrl.trim()
|
||||
? recording
|
||||
? "Tap again when you finish speaking."
|
||||
: "Tap the button to start a new voice message."
|
||||
: "Open settings to add your backend URL before sending voice messages."}
|
||||
{isHolding
|
||||
? releaseLabel
|
||||
: backendUrl.trim()
|
||||
? holdLabel
|
||||
: openSettingsLabel}
|
||||
</Text>
|
||||
|
||||
{responsePreview ? (
|
||||
<View style={styles.responseBox}>
|
||||
<Text style={styles.responseLabel}>Server response</Text>
|
||||
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
|
||||
<Text style={styles.responseText}>{responsePreview}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,17 +440,17 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 32,
|
||||
paddingTop: 8,
|
||||
gap: 18,
|
||||
},
|
||||
hero: {
|
||||
backgroundColor: "#13304a",
|
||||
borderRadius: 28,
|
||||
backgroundColor: "transparent",
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 24,
|
||||
gap: 12,
|
||||
paddingTop: 40,
|
||||
},
|
||||
heroTopRow: {
|
||||
alignItems: "center",
|
||||
@@ -351,7 +458,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
heroBadge: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#f2b15d",
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
@@ -364,22 +470,14 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
settingsLink: {
|
||||
borderColor: "#58718d",
|
||||
settingsCog: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
},
|
||||
settingsLinkText: {
|
||||
color: "#d3deea",
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
},
|
||||
subtitle: {
|
||||
color: "#d3deea",
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
backgroundColor: "#13304a",
|
||||
marginLeft: 12,
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: "#fffaf1",
|
||||
@@ -388,6 +486,8 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 18,
|
||||
alignSelf: "center",
|
||||
maxWidth: 340,
|
||||
},
|
||||
meterValueCentered: {
|
||||
color: "#d04f2d",
|
||||
@@ -404,17 +504,21 @@ const styles = StyleSheet.create({
|
||||
width: 164,
|
||||
alignSelf: "center",
|
||||
},
|
||||
recordButton: {
|
||||
backgroundColor: "#d04f2d",
|
||||
idleButton: {
|
||||
backgroundColor: "#13304a",
|
||||
},
|
||||
stopButton: {
|
||||
backgroundColor: "#8c1c13",
|
||||
holdingButton: {
|
||||
backgroundColor: "#d04f2d",
|
||||
transform: [{ scale: 1.08 }],
|
||||
},
|
||||
micButtonText: {
|
||||
color: "#fff6f3",
|
||||
fontSize: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: "800",
|
||||
},
|
||||
recordingLabel: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
@@ -422,11 +526,13 @@ const styles = StyleSheet.create({
|
||||
color: "#1f2d3d",
|
||||
fontSize: 15,
|
||||
lineHeight: 21,
|
||||
textAlign: "center",
|
||||
},
|
||||
helperText: {
|
||||
color: "#665f54",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
responseBox: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
@@ -440,6 +546,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
},
|
||||
responseText: {
|
||||
color: "#36475a",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Picker } from "@react-native-picker/picker";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -16,11 +17,20 @@ import {
|
||||
loadRecorderSettings,
|
||||
saveRecorderSettings,
|
||||
} from "@/lib/recorder-settings";
|
||||
import { AVAILABLE_LOCALES, t, type Locale, getStrings } from "@/lib/translations";
|
||||
|
||||
function localeLabel(locale: Locale) {
|
||||
if (locale === "ca") return "Catal\u00e0";
|
||||
if (locale === "en") return "English";
|
||||
return locale;
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [backendUrl, setBackendUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [fieldName, setFieldName] = useState("file");
|
||||
const [language, setLanguage] = useState<Locale>("ca");
|
||||
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -36,9 +46,11 @@ export default function SettingsScreen() {
|
||||
setBackendUrl(settings.backendUrl);
|
||||
setAuthToken(settings.authToken);
|
||||
setFieldName(settings.fieldName);
|
||||
setLanguage(settings.language);
|
||||
setStrings(getStrings(settings.language));
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
Alert.alert("Settings error", "Could not load backend settings.");
|
||||
Alert.alert(strings.loadError, strings.loadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,19 +62,28 @@ export default function SettingsScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const langStrings = getStrings(language);
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await saveRecorderSettings({
|
||||
authToken,
|
||||
backendUrl,
|
||||
fieldName,
|
||||
language,
|
||||
});
|
||||
setStrings(getStrings(language));
|
||||
router.back();
|
||||
} catch {
|
||||
Alert.alert("Save failed", "Could not save backend settings.");
|
||||
Alert.alert(langStrings.saveError, langStrings.saveError);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLanguageChange(val: Locale) {
|
||||
setLanguage(val);
|
||||
setStrings(getStrings(val));
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
@@ -76,54 +97,73 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable onPress={() => router.back()} style={styles.navButton}>
|
||||
<Text style={styles.navButtonText}>Back</Text>
|
||||
<Text style={styles.navButtonText}>{langStrings.back}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
|
||||
<Pressable onPress={handleSave} style={styles.navButton}>
|
||||
<Text style={styles.navButtonText}>Save</Text>
|
||||
<Text style={styles.navButtonText}>{langStrings.save}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.label}>Backend URL</Text>
|
||||
<Text style={styles.label}>{langStrings.backendUrl}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
onChangeText={setBackendUrl}
|
||||
placeholder="https://api.example.com/upload"
|
||||
placeholder={langStrings.urlPlaceholder}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
style={styles.input}
|
||||
value={backendUrl}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Bearer token</Text>
|
||||
<Text style={styles.label}>{langStrings.bearerToken}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setAuthToken}
|
||||
placeholder="Optional"
|
||||
placeholder={langStrings.tokenOptional}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
value={authToken}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Form field name</Text>
|
||||
<Text style={styles.label}>{langStrings.formFieldName}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setFieldName}
|
||||
placeholder="file"
|
||||
placeholder={langStrings.fieldNamePlaceholder}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
style={styles.input}
|
||||
value={fieldName}
|
||||
/>
|
||||
|
||||
<Text style={styles.helperText}>
|
||||
The recording is uploaded as multipart field `{fieldName.trim() || "file"}`.
|
||||
{t("helperText", language, fieldName.trim() || "file")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.label}>{langStrings.languageTitle}</Text>
|
||||
<View style={styles.pickerWrapper}>
|
||||
<Picker
|
||||
selectedValue={language}
|
||||
onValueChange={handleLanguageChange}
|
||||
style={styles.picker}
|
||||
>
|
||||
{AVAILABLE_LOCALES.map((loc) => (
|
||||
<Picker.Item
|
||||
key={loc}
|
||||
label={localeLabel(loc)}
|
||||
value={loc}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
@@ -199,4 +239,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
pickerWrapper: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
borderColor: "#d9ccb5",
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
picker: {
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
import type { Locale } from "./translations";
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
authToken: "recorder.authToken",
|
||||
backendUrl: "recorder.backendUrl",
|
||||
fieldName: "recorder.fieldName",
|
||||
language: "recorder.language",
|
||||
};
|
||||
|
||||
export type RecorderSettings = {
|
||||
authToken: string;
|
||||
backendUrl: string;
|
||||
fieldName: string;
|
||||
language: Locale;
|
||||
};
|
||||
|
||||
export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
@@ -17,6 +21,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
STORAGE_KEYS.backendUrl,
|
||||
STORAGE_KEYS.authToken,
|
||||
STORAGE_KEYS.fieldName,
|
||||
STORAGE_KEYS.language,
|
||||
]);
|
||||
|
||||
const values = Object.fromEntries(entries);
|
||||
@@ -25,6 +30,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
authToken: values[STORAGE_KEYS.authToken] ?? "",
|
||||
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
|
||||
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
|
||||
language: (values[STORAGE_KEYS.language] as Locale) ?? "ca",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,5 +39,6 @@ export async function saveRecorderSettings(settings: RecorderSettings) {
|
||||
[STORAGE_KEYS.backendUrl, settings.backendUrl],
|
||||
[STORAGE_KEYS.authToken, settings.authToken],
|
||||
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
|
||||
[STORAGE_KEYS.language, settings.language],
|
||||
]);
|
||||
}
|
||||
|
||||
100
apk/lib/translations/index.ts
Normal file
100
apk/lib/translations/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type TranslationKeys = ReturnType<typeof getStrings>;
|
||||
|
||||
export function en() {
|
||||
return {
|
||||
appTitle: "Quibot Control",
|
||||
settingsTitle: "Settings",
|
||||
back: "Back",
|
||||
save: "Save",
|
||||
backendUrl: "Backend URL",
|
||||
bearerToken: "Bearer token",
|
||||
formFieldName: "Form field name",
|
||||
tokenOptional: "Optional",
|
||||
fieldNamePlaceholder: "file",
|
||||
urlPlaceholder: "https://api.example.com/upload",
|
||||
helperText: `The recording is uploaded as multipart field '{field}'.`,
|
||||
savedAlert: "Settings saved.",
|
||||
loadError: "Could not load backend settings.",
|
||||
saveError: "Could not save backend settings.",
|
||||
languageTitle: "Language",
|
||||
recorderTitle: "Voice recorder",
|
||||
readyToRecord: "Ready to record.",
|
||||
recording: "Recording...",
|
||||
micPermissionDenied: "Microphone permission was denied.",
|
||||
micAccessRequiredTitle: "Microphone access required",
|
||||
micAccessRequiredMsg: "Enable microphone access to record audio.",
|
||||
couldNotStartRecording: "Could not start recording.",
|
||||
recordingFailedTitle: "Recording failed",
|
||||
finishedUpload: "Recording finished. Uploading...",
|
||||
voiceMessageSent: "Voice message sent.",
|
||||
uploadFailed: "Upload failed.",
|
||||
noBackendUrl: "Recording finished. No backend URL set.",
|
||||
stopFailedTitle: "Stop failed",
|
||||
missingBackendUrlTitle: "Missing backend URL",
|
||||
missingBackendUrlMsg: "Enter the backend endpoint first.",
|
||||
uploadingRecording: "Uploading recording.",
|
||||
uploadComplete: "Upload complete.",
|
||||
serverResponse: "Server response",
|
||||
releaseToStop: "Release your finger to stop recording and send the audio.",
|
||||
holdToRecord: "Hold the microphone button to record a voice message. Release to send it immediately.",
|
||||
openSettingsHint: "Open settings to add your backend URL before sending voice messages.",
|
||||
};
|
||||
}
|
||||
|
||||
export function ca() {
|
||||
return {
|
||||
appTitle: "Quibot Control",
|
||||
settingsTitle: "Configuraci\u00f3",
|
||||
back: "Enrere",
|
||||
save: "Desa",
|
||||
backendUrl: "URL del servidor",
|
||||
bearerToken: "Bearer token",
|
||||
formFieldName: "Nom del camp del formulari",
|
||||
tokenOptional: "Opcional",
|
||||
fieldNamePlaceholder: "file",
|
||||
urlPlaceholder: "https://api.example.com/upload",
|
||||
helperText: `La gravaci\u00f3 es penja com el camp multipart '{field}'.`,
|
||||
savedAlert: "Configuraci\u00f3 desada.",
|
||||
loadError: "No s'han pogut carregar les configuracions.",
|
||||
saveError: "No s'han pogut desar les configuracions.",
|
||||
languageTitle: "Llenguatge",
|
||||
recorderTitle: "Enregistrador de veu",
|
||||
readyToRecord: "Preparat per enregistrar.",
|
||||
recording: "Enregistrant...",
|
||||
micPermissionDenied: "S'ha denegat el perm\u00eds del micr\u00f2fon.",
|
||||
micAccessRequiredTitle: "Acc\u00e9s al micr\u00f2fon necess\u00e0ri",
|
||||
micAccessRequiredMsg: "Activa l'acc\u00e9s al micr\u00f2fon per enregistrar \u00e0udio.",
|
||||
couldNotStartRecording: "No s'ha pogut iniciar l'enregistrament.",
|
||||
recordingFailedTitle: "L'enregistrament ha fallat",
|
||||
finishedUpload: "Gravaci\u00f3 finalitzada. S'est\u00e0 penjant...",
|
||||
voiceMessageSent: "Missatge de veu enviat.",
|
||||
uploadFailed: "No s'ha pogut penjar.",
|
||||
noBackendUrl: "Gravaci\u00f3 finalitzada. No hi ha URL configurada.",
|
||||
stopFailedTitle: "S'ha aturat",
|
||||
missingBackendUrlTitle: "Falta la URL del servidor",
|
||||
missingBackendUrlMsg: "Introdueix primer l'URL del servidor.",
|
||||
uploadingRecording: "S'est\u00e0 penjant la gravaci\u00f3.",
|
||||
uploadComplete: "Penjada completada.",
|
||||
serverResponse: "Resposta del servidor",
|
||||
releaseToStop: "Allibera el dit per aturar l'enregistrament i enviar l'\u00e0udio.",
|
||||
holdToRecord: "Mant\u00e9s premut el micr\u00f2fon per enregistrar un missatge de veu. Allibera'l per enviar-lo immediatament.",
|
||||
openSettingsHint: "Obre la configuraci\u00f3 per afegir l'URL del servidor abans d'enviar missatges de veu.",
|
||||
};
|
||||
}
|
||||
|
||||
const translations = { en, ca };
|
||||
export type Locale = keyof typeof translations;
|
||||
const DEFAULT_LOCALE: Locale = "ca";
|
||||
export const AVAILABLE_LOCALES: readonly Locale[] = Object.keys(translations) as Locale[];
|
||||
|
||||
export function getStrings(locale: Locale) {
|
||||
const fn = translations[locale] ?? en;
|
||||
return fn();
|
||||
}
|
||||
|
||||
export function t(key: keyof ReturnType<typeof ca>, locale: Locale, field?: string) {
|
||||
const strings = getStrings(locale);
|
||||
const value = strings[key as keyof typeof strings];
|
||||
if (typeof value !== "string") return String(value);
|
||||
return field ? value.replace("{field}", field) : value;
|
||||
}
|
||||
170
apk/package-lock.json
generated
170
apk/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-picker/picker": "^2.11.4",
|
||||
"expo": "~54.0.33",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
@@ -19,6 +20,7 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2769,6 +2771,19 @@
|
||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-picker/picker": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz",
|
||||
"integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -4538,6 +4553,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@@ -5061,6 +5082,56 @@
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -5277,6 +5348,61 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
@@ -5346,6 +5472,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-editor": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||
@@ -8736,6 +8874,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
@@ -9337,6 +9481,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nullthrows": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||
@@ -10243,6 +10399,20 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.15.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz",
|
||||
"integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-picker/picker": "^2.11.4",
|
||||
"expo": "~54.0.33",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
@@ -20,8 +21,9 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
|
||||
Reference in New Issue
Block a user