add whisper download utils. add react-navigator.

This commit is contained in:
Jordan 2025-02-16 19:55:26 -08:00
parent 081ac367ba
commit bc3d481d25
11 changed files with 682 additions and 85 deletions

View File

@ -1,19 +1,21 @@
import { Stack } from 'expo-router';
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import SettingsComponent from '@/components/Settings';
import { LanguageSelection } from '@/components/LanguageSelection';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import ConversationThread from '@/components/ConversationThread';
import Home from '.';
const Stack = createNativeStackNavigator();
export default function Layout() {
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#f4511e',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
</Stack>
<NavigationContainer>
<Stack.Navigator initialRouteName='LanguageSelection'>
<Stack.Screen name="LanguageSelection" component={Home} />
<Stack.Screen name="ConversationThread" component={ConversationThread} />
<Stack.Screen name="Settings" component={SettingsComponent} />
</Stack.Navigator>
</NavigationContainer>
);
}
}

View File

@ -1,6 +1,7 @@
import { Cache } from "react-native-cache";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Settings } from "../lib/settings";
type language_t = string;
@ -51,6 +52,11 @@ export class LanguageServer {
throw new Error(`Can't extract values from data: ${JSON.stringify(data)}`)
}
}
static async getDefault() {
const settings = await Settings.getDefault();
return new LanguageServer(await settings.getLibretranslateBaseUrl());
}
}
export class Translator {

View File

@ -1,10 +1,9 @@
import { LanguageSelection } from "@/components/LanguageSelection";
import { Link, Stack } from "expo-router";
import { useNavigation } from '@react-navigation/native';
import { useState } from "react";
import { Image, Text, View, StyleSheet, Button, Pressable } from "react-native";
import { Translator, language_matrix_entry } from "./i18n/api";
import ConversationThread from "@/components/ConversationThread";
import { LanguageServer, Translator, language_matrix_entry } from "./i18n/api";
import { Conversation } from "./lib/conversation";
import { LanguageSelection } from "@/components/LanguageSelection";
function LogoTitle() {
return (
@ -16,21 +15,25 @@ function LogoTitle() {
}
export default function Home() {
const navigation = useNavigation();
const [lang, setLang] = useState<language_matrix_entry | undefined>();
const [conversation, setConversation] = useState<Conversation | undefined>();
const [setShowSettings, showSettings] = useState<boolean>(false);
function onLangSelected(lang: language_matrix_entry | undefined) {
console.log("Language %s selected", lang?.code);
async function onLangSelected(lang: language_matrix_entry) {
console.log("Language %s selected", lang.code);
setLang(lang);
if (!lang?.code) return;
setConversation(
new Conversation(
new Translator("en", lang.code),
{ id: "host", language: "en" },
{ id: "guest", language: lang.code }
)
const langServer = await LanguageServer.getDefault();
const conversation = new Conversation(
new Translator("en", lang.code, langServer),
{ id: "host", language: "en" },
{ id: "guest", language: lang.code }
);
navigation.navigate("Conversation", {
conversation,
});
}
function onGoBack() {
@ -39,9 +42,7 @@ export default function Home() {
return (
<View style={styles.container}>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="conversation" />
<LanguageSelection onLangSelected={onLangSelected} />
</View>
);
}

View File

@ -7,12 +7,22 @@ export const MIGRATE_UP = {
libretranslate_base_url TEXT,
ui_direction INTEGER
)`,
],
2: [
`CREATE TABLE IF NOT EXISTS whisper_models (
model TEXT PRIMARY KEY,
bytes_done INTEGER,
bytes_total INTEGER,
)`,
]
}
export const MIGRATE_DOWN = {
1: [
`DROP TABLE IF EXISTS settings`
],
2: [
`DROP TABLE IF EXISTS whisper_models`
]
}

View File

@ -1,4 +1,6 @@
import { SQLiteDatabase } from "expo-sqlite";
import FileSystem from "expo-file-system"
import { getDb } from "./db";
export class Settings {
@ -6,6 +8,7 @@ export class Settings {
"host_language",
"libretranslate_base_url",
'ui_direction',
"wisper_model",
]
constructor(public db: SQLiteDatabase) {
@ -53,4 +56,16 @@ LIMIT 1`
return await this.getValue("libretranslate_base_url")
}
async setWhisperModel(value : string) {
await this.setValue("whisper_model", value);
}
async getWhisperModel() {
return await this.getValue("whisper_model");
}
static async getDefault() {
return new Settings(await getDb())
}
}

190
app/lib/whisper.ts Normal file
View File

@ -0,0 +1,190 @@
import { Platform } from "react-native";
import FileSystem from "expo-file-system";
import { File, Paths } from 'expo-file-system/next';
import { getDb } from "./db";
export const WHISPER_MODEL_PATH = Paths.join(FileSystem.bundleDirectory || "file:///", "whisper");
export const WHISPER_MODEL_DIR = new File(WHISPER_MODEL_PATH);
// Thanks to https://medium.com/@fabi.mofar/downloading-and-saving-files-in-react-native-expo-5b3499adda84
export async function saveFile(
uri: string,
filename: string,
mimetype: string
) {
if (Platform.OS === "android") {
const permissions =
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
filename,
mimetype
)
.then(async (uri) => {
await FileSystem.writeAsStringAsync(uri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
})
.catch((e) => console.log(e));
} else {
shareAsync(uri);
}
} else {
shareAsync(uri);
}
}
function shareAsync(uri: string) {
throw new Error("Function not implemented.");
}
export const WHISPER_MODEL_TAGS = ["small", "medium", "large"];
export type whisper_model_tag_t = (typeof WHISPER_MODEL_TAGS)[number];
export const WHISPER_MODELS = {
small: {
source:
"https://huggingface.co/openai/whisper-small/blob/main/pytorch_model.bin",
target: "small.bin",
label: "Small",
},
medium: {
source:
"https://huggingface.co/openai/whisper-medium/blob/main/pytorch_model.bin",
target: "medium.bin",
label: "Medium",
},
large: {
source:
"https://huggingface.co/openai/whisper-large/blob/main/pytorch_model.bin",
target: "large.bin",
label: "Large",
},
} as {
[key: whisper_model_tag_t]: { source: string; target: string; label: string };
};
export function getWhisperTarget(key : whisper_model_tag_t) {
const path = Paths.join(WHISPER_MODEL_DIR, WHISPER_MODELS[key].target);
return new File(path)
}
export type download_status =
| {
status: "not_started" | "complete";
}
| {
status: "in_progress";
bytes: {
total: number;
done: number;
};
};
export async function getModelFileSize(whisper_model: whisper_model_tag_t) {
const target = getWhisperTarget(whisper_model)
if (!target.exists) return undefined;
return target.size;
}
/**
*
* @param whisper_model The whisper model key to check (e.g. `"small"`)
* @returns
*/
export async function getWhisperDownloadStatus(
whisper_model: whisper_model_tag_t
): Promise<download_status> {
// const files = await FileSystem.readDirectoryAsync("file:///whisper");
const result = (await (
await getDb()
).getFirstSync(
`
SELECT (bytes_done, total) WHERE model = ?
`,
[whisper_model]
)) as { bytes_done: number; total: number } | undefined;
if (!result)
return {
status: "not_started",
};
if (result.bytes_done < result.total)
return {
status: "in_progress",
bytes: {
done: result.bytes_done,
total: result.total,
},
};
return {
status: "complete",
};
}
export function whisperFileExists(whisper_model : whisper_model_tag_t) {
const target = getWhisperTarget(whisper_model);
return target.exists
}
export async function downloadWhisperModel(
whisper_model: whisper_model_tag_t,
options: {
force_redownload: boolean;
} = {
force_redownload: false,
}
) {
if (!WHISPER_MODEL_DIR.exists) {
await FileSystem.makeDirectoryAsync(WHISPER_MODEL_PATH, {
intermediates: true,
})
}
const whisperTarget = getWhisperTarget(whisper_model);
// If the target file exists, delete it.
if (whisperTarget.exists) {
if (options.force_redownload) {
whisperTarget.delete()
} else {
console.warn("Whisper model for %s already exists", whisper_model);
return;
}
}
// Initiate a new resumable download.
const spec = WHISPER_MODELS[whisper_model];
const resumable = FileSystem.createDownloadResumable(
spec.source,
whisperTarget.uri,
undefined,
// On each data write, update the whisper model download status.
// Note that since createDownloadResumable callback only works in the foreground,
// a background process will also be updating the file size.
async (data) => {
const db = await getDb();
const args = [
whisper_model,
data.totalBytesWritten,
data.totalBytesExpectedToWrite,
];
await db.runAsync(
`INSERT OR REPLACE INTO whisper_models (model, bytes_done, bytes_remaining) VALUES (?, ?, ?)`,
args
);
}
);
await resumable.downloadAsync();
}

36
app/service/download.ts Normal file
View File

@ -0,0 +1,36 @@
import { useState, useEffect, useRef } from 'react';
import { Text, View, Button, Platform } from 'react-native';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
export function initNotifications() {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
}
async function sendPushNotification(expoPushToken: string) {
const message = {
to: expoPushToken,
sound: 'default',
title: 'Original Title',
body: 'And here is the body!',
data: { someData: 'goes here' },
};
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
Accept: 'application/json',
'Accept-encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
}

View File

@ -1,71 +1,71 @@
import React, { useState, useEffect } from "react";
import { ScrollView, Text, TouchableHighlight, View } from "react-native";
import { useNavigation, Route } from "@react-navigation/native";
import { Conversation, Message } from "@/app/lib/conversation";
import MessageBubble from "@/components/ui/MessageBubble";
import { WhisperContext } from "whisper.rn";
import { NavigationProp, ParamListBase } from "@react-navigation/native";
import {
CachedTranslator,
language_matrix_entry,
Translator,
} from "@/app/i18n/api";
import { CachedTranslator, LanguageServer } from "@/app/i18n/api";
import { getDb } from "@/app/lib/db";
import LiveAudioStream from 'react-native-live-audio-stream';
import LiveAudioStream from "react-native-live-audio-stream";
const lasOptions = {
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
channels: 1, // 1 or 2, default 1
bitsPerSample: 16, // 8 or 16, default 16
audioSource: 6, // android only (see below)
bufferSize: 4096 // default is 2048
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
channels: 1, // 1 or 2, default 1
bitsPerSample: 16, // 8 or 16, default 16
audioSource: 6, // android only (see below)
bufferSize: 4096, // default is 2048
};
// LiveAudioStream.init(lasOptions as any);
interface ConversationThreadProps {
conversation: Conversation;
whisperContext: WhisperContext;
onGoBack?: () => any;
}
const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversation : Conversation}>}) => {
const navigation = useNavigation();
if (!route) {
return (<View><Text>Missing Params!</Text></View>)
}
/* 2. Get the param */
const { conversation } = route?.params;
const ConversationThread = (p: ConversationThreadProps) => {
const [messages, setMessages] = useState<Message[]>([]);
const [guestSpeak, setGuestSpeak] = useState<string | undefined>();
const [guestSpeakLoaded, setGuestSpeakLoaded] = useState<boolean>(false);
const ct = new CachedTranslator("en", p.conversation.guest.language);
const [cachedTranslator, setCachedTranslator] = useState<
undefined | CachedTranslator
>();
useEffect(() => {
(async () => {
setCachedTranslator(
new CachedTranslator(
"en",
conversation.guest.language,
await LanguageServer.getDefault()
)
);
if (!cachedTranslator) throw new Error("cachedTranslator is undefined");
setGuestSpeak(await cachedTranslator.translate("Speak"));
})();
const updateMessages = (c: Conversation) => {
setMessages([...c]);
};
p.conversation.onAddMessage = updateMessages;
p.conversation.onTranslationDone = updateMessages;
conversation.onAddMessage = updateMessages;
conversation.onTranslationDone = updateMessages;
return () => {
p.conversation.onAddMessage = undefined;
p.conversation.onTranslationDone = undefined;
conversation.onAddMessage = undefined;
conversation.onTranslationDone = undefined;
};
}, [p.conversation, guestSpeak]);
useEffect(() => {
const fetchData = async () => {
setGuestSpeak(await ct.translate("Speak"));
}
fetchData();
}, [guestSpeak])
}, [conversation, guestSpeak]);
const renderMessages = () =>
messages.map((message, index) => (
<MessageBubble key={index} message={message} />
));
function onGoBack() {
p.onGoBack && p.onGoBack();
}
return (
return cachedTranslator ? (
<View style={{ flex: 1, flexDirection: "column" }}>
<ScrollView
style={{
@ -85,7 +85,7 @@ const ConversationThread = (p: ConversationThreadProps) => {
</TouchableHighlight>
<TouchableHighlight
style={{ backgroundColor: "gray", padding: 3, borderRadius: 5 }}
onPress={onGoBack}
onPress={navigation.goBack}
>
<Text style={{ color: "white", fontSize: 30 }}>Go Back</Text>
</TouchableHighlight>
@ -98,6 +98,10 @@ const ConversationThread = (p: ConversationThreadProps) => {
</TouchableHighlight>
</View>
</View>
) : (
<View>
<Text>Loading...</Text>
</View>
);
};

View File

@ -1,12 +1,13 @@
// Import necessary packages
import React, { useState, useEffect } from "react";
import { View, Text, TextInput, StyleSheet } from "react-native"; // Add Picker import
import { View, Text, TextInput, StyleSheet, Pressable } from "react-native"; // Add Picker import
import { getDb } from "@/app/lib/db";
import { Settings } from "@/app/lib/settings";
import { LanguageServer } from "@/app/i18n/api";
import {Picker} from "@react-native-picker/picker"
import { longLang } from "@/app/i18n/lang";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import { WHISPER_MODELS, downloadWhisperModel, download_status, getWhisperDownloadStatus, whisper_model_tag_t } from "@/app/lib/whisper";
type Language = {
code: string;
@ -22,6 +23,8 @@ const SettingsComponent: React.FC = () => {
const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState<string | null>(null);
const [languages, setLanguages] = useState<undefined|LanguageMatrix>();
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [whisperModel, setWhisperModel] = useState<undefined|whisper_model_tag_t>()
const [downloadStatus, setDownloadStatus] = useState<undefined|download_status>();
useEffect(() => {
(async () => {
@ -33,16 +36,35 @@ const SettingsComponent: React.FC = () => {
const hostLang = await settings.getHostLanguage();
const libretranslateUrl = await settings.getLibretranslateBaseUrl();
const langServer = new LanguageServer(libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL);
const wModel = await settings.getWhisperModel()
// Fetch languages from API
const langData = await langServer.fetchLanguages();
setLanguages(langData);
setHostLanguage(hostLang || "en");
setLibretranslateBaseUrl(libretranslateUrl);
setWhisperModel(wModel);
setIsLoaded(true);
})();
// Check for whether a model is currently downloading and set the status.
setInterval(async () => {
if (!whisperModel) return null;
const dlStatus = await getWhisperDownloadStatus(whisperModel);
setDownloadStatus(dlStatus)
}, 200);
}, []);
const doReadownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel, {force_redownload: true});
}
const doDownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel)
}
const handleHostLanguageChange = async (value: string) => {
setHostLanguage(value);
@ -86,6 +108,38 @@ const SettingsComponent: React.FC = () => {
onChangeText={handleLibretranslateBaseUrlChange}
accessibilityHint="libretranslate base url"
/>
<Picker
selectedValue={whisperModel || ""}
style={{ height: 50, width: "100%" }}
onValueChange={setWhisperModel}
accessibilityHint="language"
>
{Object.entries(WHISPER_MODELS).map(([key, {label}]) => (
<Picker.Item key={key} label={label} value={key} />
))}
</Picker>
{whisperModel && (
<View>
{
downloadStatus?.status === "complete" ? (
<Pressable onPress={doReadownload}>
Re-Download
</Pressable>
) : (
downloadStatus?.status === "in_progress" ? (
<Text>
{downloadStatus.bytes.done / downloadStatus.bytes.total * 100.0} % complete
{ downloadStatus.bytes.done } bytes of { downloadStatus.bytes.total }
</Text>
) : (
<Pressable onPress={doDownload}>
Download
</Pressable>
)
)
}
</View>
)}
</View> : <View><Text>Loading ...</Text></View>
);
};

302
package-lock.json generated
View File

@ -14,16 +14,20 @@
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"expo": "~52.0.28",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.5",
"expo-constants": "~17.0.6",
"expo-device": "~7.0.2",
"expo-file-system": "^18.0.10",
"expo-font": "~13.0.3",
"expo-haptics": "~14.0.1",
"expo-linking": "~7.0.5",
"expo-notifications": "~0.29.13",
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "^13.0.1",
"expo-splash-screen": "~0.29.21",
"expo-sqlite": "~15.1.2",
"expo-status-bar": "~2.0.1",
@ -49,10 +53,13 @@
"@babel/core": "^7.26.7",
"@babel/preset-typescript": "^7.26.0",
"@jest/globals": "^29.7.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/stack": "^7.1.1",
"@testing-library/react-native": "^13.0.1",
"@types/jest": "^29.5.14",
"@types/react": "~18.3.18",
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-navigation": "^3.0.8",
"@types/react-test-renderer": "^18.3.1",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
@ -2519,15 +2526,15 @@
}
},
"node_modules/@expo/config": {
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.8.tgz",
"integrity": "sha512-RaKwi8e6PbkMilRexdsxObLMdQwxhY6mlgel+l/eW+IfIw8HEydSU0ERlzYUjlGJxHLHUXe4rC2vw8FEvaowyQ==",
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/@expo/config/-/config-10.0.10.tgz",
"integrity": "sha512-wI9/iam3Irk99ADGM/FyD7YrrEibIZXR4huSZiU5zt9o3dASOKhqepiNJex4YPiktLfKhYrpSEJtwno1g0SrgA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
"@expo/config-plugins": "~9.0.14",
"@expo/config-types": "^52.0.3",
"@expo/json-file": "^9.0.1",
"@expo/config-plugins": "~9.0.15",
"@expo/config-types": "^52.0.4",
"@expo/json-file": "^9.0.2",
"deepmerge": "^4.3.1",
"getenv": "^1.0.0",
"glob": "^10.4.2",
@ -2929,9 +2936,9 @@
}
},
"node_modules/@expo/json-file": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.1.tgz",
"integrity": "sha512-ZVPhbbEBEwafPCJ0+kI25O2Iivt3XKHEKAADCml1q2cmOIbQnKgLyn8DpOJXqWEyRQr/VWS+hflBh8DU2YFSqg==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.0.2.tgz",
"integrity": "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
@ -3388,6 +3395,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -4696,6 +4709,25 @@
"nanoid": "3.3.8"
}
},
"node_modules/@react-navigation/stack": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.1.1.tgz",
"integrity": "sha512-CBTKQlIkELp05zRiTAv5Pa7OMuCpKyBXcdB3PGMN2Mm55/5MkDsA1IaZorp/6TsVCdllITD6aTbGX/HA/88A6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^2.2.5",
"color": "^4.2.3"
},
"peerDependencies": {
"@react-navigation/native": "^7.0.14",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-gesture-handler": ">= 2.0.0",
"react-native-safe-area-context": ">= 4.0.0",
"react-native-screens": ">= 4.0.0"
}
},
"node_modules/@remix-run/node": {
"version": "2.15.3",
"resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.15.3.tgz",
@ -5070,6 +5102,17 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-native": {
"version": "0.72.8",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz",
"integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@react-native/virtualized-lists": "^0.72.4",
"@types/react": "*"
}
},
"node_modules/@types/react-native-sqlite-storage": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@types/react-native-sqlite-storage/-/react-native-sqlite-storage-6.0.5.tgz",
@ -5077,6 +5120,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.72.8",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz",
"integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4",
"nullthrows": "^1.1.1"
},
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/@types/react-navigation": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/react-navigation/-/react-navigation-3.0.8.tgz",
"integrity": "sha512-r8UQvBmOz7XjPE8AHTHh0SThGqModhQtSsntkmob7rczhueJIqDwBOgsEn54SJa25XzD/KBlelAWeVZ7+Ggm8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"node_modules/@types/react-test-renderer": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.1.tgz",
@ -5618,6 +5686,19 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/ast-types": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz",
@ -5897,6 +5978,12 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -7064,6 +7151,23 @@
"node": ">=8"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/del": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz",
@ -7730,6 +7834,15 @@
}
}
},
"node_modules/expo-application": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.0.2.tgz",
"integrity": "sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.0.3.tgz",
@ -7747,6 +7860,18 @@
"react-native": "*"
}
},
"node_modules/expo-background-fetch": {
"version": "13.0.5",
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-13.0.5.tgz",
"integrity": "sha512-rLRM+rYDRT0fA0Oaet5ibJK3nKVRkfdjXjISHxjUvIE4ktD9pE+UjAPPdjTXZ5CkNb3JyNNhQGJEGpdJC2HLKw==",
"license": "MIT",
"dependencies": {
"expo-task-manager": "~12.0.5"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-blur": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz",
@ -7759,12 +7884,12 @@
}
},
"node_modules/expo-constants": {
"version": "17.0.5",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.5.tgz",
"integrity": "sha512-6SHXh32jCB+vrp2TRDNkoGoM421eOBPZIXX9ixI0hKKz71tIjD+LMr/P+rGUd/ks312MP3WK3j5vcYYPkCD8tQ==",
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.6.tgz",
"integrity": "sha512-rl3/hBIIkh4XDkCEMzGpmY6kWj2G1TA4Mq2joeyzoFBepJuGjqnGl7phf/71sTTgamQ1hmhKCLRNXMpRqzzqxw==",
"license": "MIT",
"dependencies": {
"@expo/config": "~10.0.8",
"@expo/config": "~10.0.9",
"@expo/env": "~0.4.1"
},
"peerDependencies": {
@ -7772,6 +7897,44 @@
"react-native": "*"
}
},
"node_modules/expo-device": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-7.0.2.tgz",
"integrity": "sha512-0PkTixE4Qi8VQBjixnj4aw2f6vE4tUZH7GK8zHROGKlBypZKcWmsA+W/Vp3RC5AyREjX71pO/hjKTSo/vF0E2w==",
"license": "MIT",
"dependencies": {
"ua-parser-js": "^0.7.33"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-device/node_modules/ua-parser-js": {
"version": "0.7.40",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz",
"integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/expo-file-system": {
"version": "18.0.10",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.10.tgz",
@ -7941,6 +8104,26 @@
"invariant": "^2.2.4"
}
},
"node_modules/expo-notifications": {
"version": "0.29.13",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.29.13.tgz",
"integrity": "sha512-GHye6XeI1uEeVttJO/hGwUyA5cgQsxR3mi5q37yOE7cZN3cMj36pIfEEmjXEr0nWIWSzoJ0w8c2QxNj5xfP1pA==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.6.4",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~6.0.2",
"expo-constants": "~17.0.5"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-router": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-4.0.17.tgz",
@ -8004,6 +8187,15 @@
"react-native": "*"
}
},
"node_modules/expo-sharing": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.0.1.tgz",
"integrity": "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-splash-screen": {
"version": "0.29.21",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.21.tgz",
@ -8082,6 +8274,19 @@
}
}
},
"node_modules/expo-task-manager": {
"version": "12.0.5",
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-12.0.5.tgz",
"integrity": "sha512-tDHOBYORA6wuO32NWwz/Egrvn+N6aANHAa0DFs+01VK/IJZfU9D05ZN6M5XYIlZv5ll4GSX1wJZyTCY0HZGapw==",
"license": "MIT",
"dependencies": {
"unimodules-app-loader": "~5.0.1"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.2.tgz",
@ -9336,6 +9541,22 @@
"node": ">=0.10.0"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -12352,6 +12573,51 @@
"node": ">=0.10.0"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/object.assign": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0",
"has-symbols": "^1.1.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -15736,6 +16002,12 @@
"node": ">=4"
}
},
"node_modules/unimodules-app-loader": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-5.0.1.tgz",
"integrity": "sha512-JI4dUMOovvLrZ1U/mrQrR73cxGH26H7NpfBxwE0hk59CBOyHO4YYpliI3hPSGgZzt+YEy2VZR6nrspSUXY8jyw==",
"license": "MIT"
},
"node_modules/unique-filename": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",

View File

@ -21,16 +21,20 @@
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"expo": "~52.0.28",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.5",
"expo-constants": "~17.0.6",
"expo-device": "~7.0.2",
"expo-file-system": "^18.0.10",
"expo-font": "~13.0.3",
"expo-haptics": "~14.0.1",
"expo-linking": "~7.0.5",
"expo-notifications": "~0.29.13",
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "^13.0.1",
"expo-splash-screen": "~0.29.21",
"expo-sqlite": "~15.1.2",
"expo-status-bar": "~2.0.1",
@ -79,10 +83,13 @@
"@babel/core": "^7.26.7",
"@babel/preset-typescript": "^7.26.0",
"@jest/globals": "^29.7.0",
"@react-navigation/native": "^7.0.14",
"@react-navigation/stack": "^7.1.1",
"@testing-library/react-native": "^13.0.1",
"@types/jest": "^29.5.14",
"@types/react": "~18.3.18",
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-navigation": "^3.0.8",
"@types/react-test-renderer": "^18.3.1",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",