Compare commits

...

10 Commits

Author SHA1 Message Date
Jordan
4549442bd8 detemine root cause of download issue. 2025-02-28 07:13:45 -08:00
Jordan
87446784ae improve tests, especially for navigation. 2025-02-27 08:23:27 -08:00
Jordan
6f941c56d1 try to fix navigation some more. 2025-02-24 09:09:20 -08:00
Jordan
c3d543be39 work on stack. 2025-02-23 19:53:20 -08:00
Jordan Hewitt
eb7599bfe8 work on navigation component workflow. 2025-02-22 18:28:10 -08:00
Jordan
6673663883 mock expo file system. 2025-02-19 06:13:10 -08:00
Jordan Hewitt
b3c2e09987 refactor stack. Add details to example tests. 2025-02-18 16:56:19 -08:00
Jordan
bc3d481d25 add whisper download utils. add react-navigator. 2025-02-16 19:55:26 -08:00
Jordan
081ac367ba add ollama files. Fix unit tests (finally). TODO: handle static file downloading and screens. 2025-02-14 15:23:22 -08:00
Jordan
68cc052417 fix some sqlite errors. 2025-02-13 07:48:32 -08:00
38 changed files with 1810 additions and 491 deletions

View File

@ -0,0 +1,39 @@
/**
* NOTE: this file is for ollama. The AI assistant should
* follow this format, but modify it according to the user's prompt.
*/
/**
* Import any necessary packages
**/
import { StyleSheet } from "react-native";
type ExampleComponentProps = {
/**
* Declare any properties here
*/
}
/**
* Don't include the typing when defiining the function. Define it normally.
*/
const ISpeakButton = (props : ExampleComponentProps) => {
// Do any housekeeping up here, like `useState`, `useEffect`, etc.
// Return the TSX component below
return (
<></>
)
}
// Create style sheets here
const styles = StyleSheet.create({
})
// Export the component
export default ISpeakButton;

View File

@ -0,0 +1,37 @@
/**
* NOTE: this file is for ollama. The AI assistant should
* follow this format, but modify it according to the user's prompt.
*/
/**
* Import any necessary packages
**/
import React, { act } from 'react';
import { render, screen } from '@testing-library/react-native'
import { MyComponent } from '@/app/component/MyComponent';
/**
* IMPORTANT NOTE: If you need to use jest mock, remember that
* you cannot include any external components in the mock.
* You absolutely must use `jest.requireActual` to import the
* component *within* the jest.mock callback.
*/
jest.mock('@/app/component/index.tsx', () => {
// Require-actual the component
const { Text } = jest.requireActual('react-native');
// Use the component.
return () => <Text>Index</Text>
});
describe('Message Component', () => {
beforeEach(() => {
// do any set up here
});
it('A test that renders a widget', async () => {
render(<MyComponent />);
// IMPORTANT: use jest.expect(...) functions for assertions.
expect(await screen.findByText("something")).toBeOnTheScreen();
});
});

View File

@ -1,3 +1,2 @@
{
"jestTestExplorer.pathToJest": "./node_modules/.bin/jest"
}

44
__mocks__/api.ts Normal file
View File

@ -0,0 +1,44 @@
// __mocks__/api.ts
import { language_matrix, language_matrix_entry } from "@/app/i18n/api";
// Import the actual API module to extend its functionality
const origApi = jest.requireActual('@/app/i18n/api.ts');
class LanguageServer {
constructor(...args: any[]) { }
fetchLanguages(): language_matrix {
return {
"en" : { code: "en", name: "English", targets: ['fr', 'es'] },
"fr" : { code: "fr", name: "French", targets: ["en", "es"] },
"es": { code: "es", name: "Spanish", targets: ['fr', 'en'] },
}
}
static getDefault() {
return new LanguageServer("http://localhost:5002");
}
}
class Translator {
constructor(...args : any []) {}
translate(message : string, target : string) {
return message;
}
static getDefault(code : string) {
return new Translator(code);
}
}
class CachedTranslator extends Translator{
}
module.exports = {
...origApi,
LanguageServer,
Translator,
CachedTranslator,
// Mock the specific functions you want to override
fetchData: jest.fn(() => Promise.resolve({ data: 'mocked data' })),
// Add more mock implementations as needed
};

10
__mocks__/db.ts Normal file
View File

@ -0,0 +1,10 @@
export default {
getDb: jest.fn(() => {
return {
runAsync: jest.fn((statement: string, value: string) => {}),
getFirstAsync: jest.fn((statement: string, value: string) => {
return [];
}),
};
}),
};

View File

@ -0,0 +1,4 @@
export const File = jest.fn();
export const Paths = {
join: jest.fn(),
};

View File

@ -1,44 +0,0 @@
// __mocks__/expo-sqlite.js
const sqlite3 = require('sqlite3').verbose();
class SQLiteDatabase {
constructor(name) {
this.db = new sqlite3.Database(':memory:');
}
runAsync(sql, params = []) {
return new Promise((resolve, reject) => {
this.db.run(sql, params, function (err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
execAsync(sql) {
return new Promise((resolve, reject) => {
this.db.exec(sql, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
closeAsync() {
return new Promise(resolve => {
this.db.close(() => resolve());
});
}
}
const SQLite = {
openDatabaseAsync: jest.fn(name => new SQLiteDatabase(name)),
};
module.exports = SQLite;

16
__mocks__/settings.ts Normal file
View File

@ -0,0 +1,16 @@
const originalModule = jest.requireActual("@/app/lib/settings");
class MockSettings {
public constructor(public db = {}) {}
public setHostLanguage = jest.fn((val: string) => {});
public setLibretranslateBaseUrl(val: string) {}
getHostLanguage = jest.fn(() => {
return "en";
});
getLibretranslateBaseUrl = jest.fn(() => {
return "http://localhost:5004";
});
}
export default {
...originalModule,
Settings: MockSettings,
};

View File

@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>

View File

@ -3,7 +3,7 @@
"name": "translation-terrace",
"slug": "translation-terrace",
"version": "1.0.0",
"orientation": "portrait",
"orientation": "landscape",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
@ -30,7 +30,7 @@
[
"expo-screen-orientation",
{
"initialOrientation": "LANDSCAPE"
"orientation": "landscape"
}
],
[
@ -48,12 +48,10 @@
"enableFTS": true,
"useSQLCipher": true,
"android": {
// Override the shared configuration for Android
"enableFTS": false,
"useSQLCipher": false
},
"ios": {
// You can also override the shared configurations for iOS
"customBuildFlags": [
"-DSQLITE_ENABLE_DBSTAT_VTAB=1 -DSQLITE_ENABLE_SNAPSHOT=1"
]

View File

@ -1,19 +1,21 @@
import { Stack } from 'expo-router';
import * as React from "react";
import TTNavStack from "@/components/TTNavStack";
import * as ScreenOrientation from "expo-screen-orientation";
import { NavigationContainer } from "@react-navigation/native";
import { migrateDb } from "./lib/db";
import { Text } from "react-native";
export default function Layout() {
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#f4511e',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
</Stack>
);
const [loaded, setLoaded] = React.useState<boolean>(true);
React.useEffect(() => {
(async function () {
await migrateDb();
await ScreenOrientation.unlockAsync();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
);
setLoaded(true);
})();
});
return loaded ? <TTNavStack /> : <Text>Loading...</Text>;
}

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;
@ -23,17 +24,23 @@ export type language_matrix = {
[key:string] : language_matrix_entry
}
export class Translator {
constructor(public source : language_t, public defaultTarget : string = "en", private baseUrl = LIBRETRANSLATE_BASE_URL) {
}
export async function fetchWithTimeout(url : string, options : RequestInit, timeout = 5000) : Promise<Response> {
return Promise.race([
fetch(url, options),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
]);
}
async fetchLanguages() : Promise<language_matrix> {
export class LanguageServer {
constructor(public baseUrl : string) {}
async fetchLanguages(timeout = 500) : Promise<language_matrix> {
let data = {};
const res = await fetch(this.baseUrl + "/languages", {
const res = await fetchWithTimeout(this.baseUrl + "/languages", {
headers: {
"Content-Type": "application/json"
}
});
}, timeout);
try {
data = await res.json();
} catch (e) {
@ -53,8 +60,22 @@ export class Translator {
}
}
static async getDefault() {
const settings = await Settings.getDefault();
return new LanguageServer(await settings.getLibretranslateBaseUrl() || LIBRETRANSLATE_BASE_URL);
}
}
export class Translator {
constructor(public source : language_t, public defaultTarget : string = "en", private _languageServer : LanguageServer) {
}
get languageServer() {
return this._languageServer;
}
async translate(text : string, target : string|undefined = undefined) {
const url = LIBRETRANSLATE_BASE_URL + `/translate`;
const url = this._languageServer.baseUrl + `/translate`;
const res = await fetch(url, {
method: "POST",
body: JSON.stringify({
@ -73,6 +94,12 @@ export class Translator {
console.log(data)
return data.translatedText
}
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage();
return new Translator(source, defaultTarget, await LanguageServer.getDefault())
}
}
export class CachedTranslator extends Translator {
@ -87,4 +114,10 @@ export class CachedTranslator extends Translator {
await cache.set(key2, tr2);
return tr2;
}
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage();
return new CachedTranslator(source, defaultTarget, await LanguageServer.getDefault())
}
}

View File

@ -12,7 +12,7 @@ export function chooseCountry(lang_a2 : string) {
c => c.languages.includes(lang_a3.alpha3)
);
console.log("cc = %x, ", cs.map(c => c.alpha2))
// console.log("cc = %x, ", cs.map(c => c.alpha2))
return cs.filter(cc => Object.keys(LANG_FLAGS).includes(cc.alpha2.toLowerCase())).map(c => c.alpha2.toLowerCase());
}

View File

@ -4,7 +4,9 @@ import _LANGUAGES from "@/assets/languages.min.json"
export const LANG_FLAGS = _LANG_FLAGS
export function longLang(shortLang : string) {
return ((LANG_FLAGS as any)[shortLang] as any)["nameEnglish"] as string
const obj = LANG_FLAGS[shortLang];
if (!obj) return undefined;
return obj["name"] as string;
}
export function lang_a3_a2(a3 : string) {

View File

@ -1,10 +1,10 @@
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";
import { Link } from 'expo-router';
function LogoTitle() {
return (
@ -16,21 +16,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 +43,10 @@ export default function Home() {
return (
<View style={styles.container}>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="conversation" />
<Pressable onPress={() => navigation.navigate("Settings")}>
<Text>Settings</Text>
</Pressable>
<LanguageSelection onLangSelected={onLangSelected} />
</View>
);
}

View File

@ -1,14 +1,15 @@
// conversation.test.ts
import { Translator } from '@/app/i18n/api';
import { LanguageServer, Translator } from '@/app/i18n/api';
import { Conversation, Message, Speaker } from '@/app/lib/conversation';
import { LIBRETRANSLATE_BASE_URL } from '@/constants/api';
import { describe, beforeEach, it, expect, test } from '@jest/globals';
describe('Conversation', () => {
let conversation: Conversation;
beforeEach(() => {
const translator = new Translator("en", "es");
const translator = new Translator("en", "es", new LanguageServer(LIBRETRANSLATE_BASE_URL));
const s1: Speaker = { language: "en", id: "host" };
const s2: Speaker = { id: "guest", language: "es" }
conversation = new Conversation(translator, s1, s2);

View File

@ -1,23 +1,27 @@
import {describe, expect, beforeEach} from '@jest/globals';
import {Settings} from '@/app/lib/settings';
import { getDb } from '@/app/lib/db';
import { getDb, migrateDb } from '@/app/lib/db';
describe('Settings', () => {
let settings: Settings;
beforeEach(async () => {
// Initialize your Settings class here with a fresh database instance
settings = new Settings(await getDb());
await migrateDb();
const db = await getDb();
if (!db) throw new Error("Could not get db");
settings = new Settings(db);
});
afterEach(async () => {
// Clean up the database after each test
await settings.db.executeSql('DELETE FROM settings');
settings && await settings.db.executeSql('DELETE FROM settings');
});
describe('setHostLanguage', () => {
it('should set the host language in the database', async () => {
const value = 'en';
await settings.db.runAsync("REPLACE INTO settings (host_language) VALUES (?)", "en");
await settings.setHostLanguage(value);
const result = await settings.getHostLanguage();
@ -28,10 +32,7 @@ describe('Settings', () => {
describe('getHostLanguage', () => {
it('should return the host language from the database', async () => {
const value = 'fr';
await settings.db.executeSql(
`INSERT INTO settings (host_language) VALUES (?)`,
[value]
);
await settings.setHostLanguage(value);
const result = await settings.getHostLanguage();
expect(result).toEqual(value);
@ -39,14 +40,14 @@ describe('Settings', () => {
it('should return null if the host language is not set', async () => {
const result = await settings.getHostLanguage();
expect(result).not.toBeNull();
expect(result).toBeNull();
});
});
describe('setLibretranslateBaseUrl', () => {
it('should set the LibreTranslate base URL in the database', async () => {
const value = 'https://example.com';
await settings.setLibetransalteBaseUrl(value);
await settings.setLibretranslateBaseUrl(value);
const result = await settings.getLibretranslateBaseUrl();
expect(result).toEqual(value);
@ -54,20 +55,9 @@ describe('Settings', () => {
});
describe('getLibretranslateBaseUrl', () => {
it('should return the LibreTranslate base URL from the database', async () => {
const value = 'https://another-example.com';
await settings.db.executeSql(
`INSERT INTO settings (libetransalte_base_url) VALUES (?)`,
[value]
);
const result = await settings.getLibretranslateBaseUrl();
expect(result).toEqual(value);
});
it('should return null if the LibreTranslate base URL is not set', async () => {
const result = await settings.getLibretranslateBaseUrl();
expect(result).not.toBeNull();
expect(result).toBeNull();
});
});
});

View File

@ -31,11 +31,15 @@ export class Message {
}
}
type conversation_event_t = "add_message" | "translation_done"
type conversation_callback_t = (conversation : Conversation) => any;
export class Conversation extends Array<Message> {
public onAddMessage? : (conversation : Conversation) => any;
public onTranslationDone? : (conversation : Conversation) => any;
constructor (
public translator : Translator,
public host : Speaker,
@ -44,6 +48,15 @@ export class Conversation extends Array<Message> {
super();
}
public on(event : conversation_event_t, callback : conversation_callback_t) {
if (event === "add_message") {
this.onAddMessage = callback;
}
if (event === "translation_done") {
this.onTranslationDone = callback;
}
}
public addMessage(speaker : Speaker, text? : string) {
this.push(new Message(this, speaker, text));
}

View File

@ -1,30 +1,26 @@
import * as SQLite from 'expo-sqlite';
import * as SQLite from "expo-sqlite";
import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations";
export const MIGRATE_UP = {
1: [
`CREATE TABLE IF NOT EXIST settings (
host_language TEXT,
libretranslate_base_url TEXT,
ui_direction INTEGER
)`,
]
export async function getDb() {
return await SQLite.openDatabaseAsync("translation_terrace");
}
export const MIGRATE_DOWN = {
1: [
`DROP TABLE IF EXISTS settings`
]
}
export async function getDb(migrationDirection : "up" | "down" = "up") {
const db = await SQLite.openDatabaseAsync('translation_terrace');
export async function migrateDb(direction: "up" | "down" = "up") {
for (let [migration, statements] of Object.entries(MIGRATE_UP)) {
for (let statement of statements) {
console.log(statement)
await db.runAsync(statement);
}
const db = await getDb();
const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN;
for (let [migration, statements] of Object.entries(m)) {
for (let statement of statements) {
console.log(statement);
try {
const result = await db.runAsync(statement);
console.log(result);
} catch (err) {
console.error(err);
}
}
return db;
}
}

23
app/lib/migrations.ts Normal file
View File

@ -0,0 +1,23 @@
export const MIGRATE_UP = {
1: [
`CREATE TABLE IF NOT EXISTS settings (
host_language TEXT,
libretranslate_base_url TEXT,
ui_direction INTEGER,
whisper_model TEXT
)`,
],
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',
"whisper_model",
]
constructor(public db: SQLiteDatabase) {
@ -19,7 +22,7 @@ export class Settings {
}
const query = `
SELECT ?
SELECT ${key}
FROM settings
LIMIT 1`
const result = await this.db.getFirstAsync(
@ -33,10 +36,8 @@ LIMIT 1`
if (!Settings.KEYS.includes(key)) {
throw new Error(`Invalid setting: '${key}'`)
}
const statement = `INSERT INTO settings (${key})
SELECT '?'
ON CONFLICT DO UPDATE SET ${key} = ?`
await this.db.runAsync(statement, [value, value]);
const statement = `REPLACE INTO settings (${key}) VALUES (?)`
await this.db.runAsync(statement, value);
}
async setHostLanguage(value: string) {
@ -47,12 +48,24 @@ ON CONFLICT DO UPDATE SET ${key} = ?`
return await this.getValue("host_language")
}
async setLibetransalteBaseUrl(value : string) {
async setLibretranslateBaseUrl(value : string) {
await this.setValue("libretranslate_base_url", value)
}
async getLibretranslateBaseUrl() {
await this.getValue("libretranslate_base_url")
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())
}
}

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

@ -0,0 +1,213 @@
import { Platform } from "react-native";
import * as 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 type DownloadCallback = (arg0 : FileSystem.DownloadProgressData) => any;
async function updateModelSize(model_label : string, size : number) {
const db = await getDb();
const query = "INSERT OR REPLACE INTO whisper_models (model, bytes_total) VALUES (?, ?)"
const stmt = db.prepareSync(query);
stmt.executeSync(model_label, size);
}
async function getExpectedModelSize(model_label : string) : Promise<number | undefined> {
const db = await getDb();
const query = "SELECT bytes_total FROM whisper_models WHERE model = ?"
const stmt = db.prepareSync(query);
const curs = stmt.executeSync(model_label);
const row = curs.getFirstSync()
return row ? row.bytes_total : undefined;
}
export async function initiateWhisperDownload(
whisper_model: whisper_model_tag_t,
options: {
force_redownload?: boolean;
onDownload?: DownloadCallback | undefined;
} = {
force_redownload: false,
onDownload: undefined,
}
) {
console.debug("Starting download of %s", whisper_model);
if (!WHISPER_MODEL_DIR.exists) {
await FileSystem.makeDirectoryAsync(WHISPER_MODEL_PATH, {
intermediates: true,
});
console.debug("Created %s", WHISPER_MODEL_DIR);
}
const whisperTarget = getWhisperTarget(whisper_model);
// If the target file exists, delete it.
if (whisperTarget.exists) {
if (options.force_redownload) {
whisperTarget.delete()
} else {
const expected = await getExpectedModelSize(whisper_model);
if (whisperTarget.size === expected) {
console.warn("Whisper model for %s already exists", whisper_model);
return undefined;
}
}
}
// Initiate a new resumable download.
const spec = WHISPER_MODELS[whisper_model];
console.log("Downloading %s", spec.source);
const resumable = FileSystem.createDownloadResumable(
spec.source,
whisperTarget.uri,
{},
// 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) => {
console.log("%s: %d bytes of %d", whisperTarget.uri, data.totalBytesWritten, data.totalBytesExpectedToWrite);
await updateModelSize(whisper_model, data.totalBytesExpectedToWrite)
if (options.onDownload) await options.onDownload(data);
},
whisperTarget.exists ? whisperTarget.base64() : undefined,
);
return resumable;
}

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,72 +1,100 @@
import React, { useState, useEffect } from "react";
import { ScrollView, Text, TouchableHighlight, View } from "react-native";
import { ScrollView, StyleSheet, 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 { getDb } from "@/app/lib/db";
import LiveAudioStream from 'react-native-live-audio-stream';
import { CachedTranslator, LanguageServer, language_matrix_entry } from "@/app/i18n/api";
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
>();
const [languageLabels, setLanguageLabels] = useState<undefined | {
hostNative: {
host: string,
guest: string,
},
guestNative: {
host: string,
guest: string,
}
}>()
useEffect(() => {
(async () => {
const languageServer = await LanguageServer.getDefault();
const languages = await languageServer.fetchLanguages();
const cc = new CachedTranslator(
"en",
conversation.guest.language,
languageServer,
)
setCachedTranslator(cc);
setGuestSpeak(await cc.translate("Speak"));
const hostLang1 = languages[conversation.host.language].name;
const guestLang1 = languages[conversation.host.language].name;
const hostLang2 = await cc.translate(languages[conversation.host.language].name);
const guestLang2 = await cc.translate(languages[conversation.host.language].name);
setLanguageLabels({
hostNative: {
host: hostLang1,
guest: guestLang1,
},
guestNative: {
host: hostLang2,
guest: guestLang2,
}
})
})();
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" }}>
{languageLabels && (<View style={styles.languageLabels}>
<Text style={styles.nativeHostLabel}>{ languageLabels.hostNative.host } / { languageLabels.hostNative.guest }</Text>
<Text style={styles.nativeGuestLabel}>{ languageLabels.guestNative.host } / { languageLabels.guestNative.guest }</Text>
</View>)
}
<ScrollView
style={{
borderColor: "black",
@ -85,7 +113,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,7 +126,23 @@ const ConversationThread = (p: ConversationThreadProps) => {
</TouchableHighlight>
</View>
</View>
) : (
<View>
<Text>Loading...</Text>
</View>
);
};
const styles = StyleSheet.create({
languageLabels: {
},
nativeHostLabel: {
},
nativeGuestLabel: {
},
})
export default ConversationThread;

View File

@ -1,23 +1,27 @@
import { CachedTranslator, Translator, language_matrix, language_matrix_entry } from "@/app/i18n/api";
import { CachedTranslator, LanguageServer, Translator, language_matrix, language_matrix_entry } from "@/app/i18n/api";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import { useEffect, useState } from "react";
import ISpeakButton from "./ui/ISpeakButton";
import { LANG_FLAGS } from "@/app/i18n/lang";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { Conversation, Speaker } from "@/app/lib/conversation";
import { NavigationProp, ParamListBase } from "@react-navigation/native";
import { Link, useNavigation } from "expo-router";
export function LanguageSelection(props: {
navigation?: NavigationProp<ParamListBase>
translator?: Translator
onLangSelected? : (lang : language_matrix_entry) => any
onLangSelected?: (lang: language_matrix_entry) => any
}) {
const [languages, setLanguages] = useState<language_matrix | undefined>();
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
const [translator, setTranslator] = useState<Translator|undefined>();
const translator = props.translator || new CachedTranslator("en")
const nav = useNavigation();
const languageServer = new LanguageServer(LIBRETRANSLATE_BASE_URL);
function onLangSelected(language: language_matrix_entry) {
props.onLangSelected && props.onLangSelected(language)
@ -25,36 +29,40 @@ export function LanguageSelection(props: {
useEffect(() => {
const fetchData = async () => {
(async () => {
try {
// Replace with your actual async data fetching logic
const languages = await translator.fetchLanguages();
setTranslator(await CachedTranslator.getDefault());
const languageServer = await LanguageServer.getDefault();
const languages = await languageServer.fetchLanguages(5000);
setLanguages(languages);
setLanguagesLoaded(true);
} catch (error) {
error = error as Response;
console.error('Error fetching data (%d %s): %s', (error as Response).status, (error as Response).statusText, (error as Response).body);
console.error('Error fetching languages from %s: %s', languageServer.baseUrl, error);
}
};
fetchData();
})();
}, []);
return (
<ScrollView >
<SafeAreaProvider >
<SafeAreaView>
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
([lang, lang_entry]) => {
return (
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} />
);
<View>
<Pressable onPress={() => nav.navigate('Settings')}>
<Text>Settings</Text>
</Pressable>
<ScrollView >
<SafeAreaProvider >
<SafeAreaView>
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
([lang, lang_entry]) => {
return (
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} translator={translator} />
);
}
) : <Text>Waiting...</Text>
}
) : <Text>Waiting...</Text>
}
</SafeAreaView>
</SafeAreaProvider>
</ScrollView>
</SafeAreaView>
</SafeAreaProvider>
</ScrollView>
</View>
)
}

View File

@ -1,89 +1,290 @@
import React, { useEffect, useState } from "react";
import { StyleSheet, View } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import {
default as ReactNativeSettings,
SettingsElement,
} from "@mmomtchev/react-native-settings";
// Import necessary packages
import React, { useState, useEffect } from "react";
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, fetchWithTimeout } from "@/app/i18n/api";
import { Picker } from "@react-native-picker/picker";
import { longLang } from "@/app/i18n/lang";
import { Translator, language_matrix } from "@/app/i18n/api";
import FileSystem, { DownloadResumable } from "expo-file-system";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import {
WHISPER_MODELS,
WHISPER_MODEL_DIR,
initiateWhisperDownload,
download_status,
getWhisperDownloadStatus,
getWhisperTarget,
whisper_model_tag_t,
} from "@/app/lib/whisper";
import { Paths } from "expo-file-system/next";
// We will store the config here
const configData: Record<string, string> = {};
type Language = {
code: string;
name: string;
};
type LanguageMatrix = {
[key: string]: Language;
};
type connection_test_t =
| {
success: true;
}
| {
success: false;
error: string;
};
const SettingsComponent: React.FC = () => {
const [hostLanguage, setHostLanguage] = useState<string | null>(null);
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
>();
const [langServerConn, setLangServerConn] = useState<
undefined | connection_test_t
>();
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState<
FileSystem.DownloadProgressData | undefined
>();
const [downloader, setDownloader] = useState<DownloadResumable | undefined>();
const fillHostLanguageOptions = async () => {
const settings = await Settings.getDefault();
const hostLang = await settings.getHostLanguage();
setHostLanguage(hostLang || "en");
const langServer = new LanguageServer(
libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL
);
// Fetch languages from API
try {
const langData = await langServer.fetchLanguages();
setLanguages(langData);
setLangServerConn({ success: true });
} catch (err) {
console.warn("Got an error fetching: %s", err);
setLangServerConn({
success: false,
error: `Could not connect to ${libretranslateBaseUrl}: ${err}`,
});
}
};
useEffect(() => {
(async () => {
// Fetch the database connection
// const db = await getDb("down");
const settings = await Settings.getDefault();
await fillHostLanguageOptions();
console.log("Fetched settings");
// Get the current settings values
const libretranslateUrl =
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL;
setLibretranslateBaseUrl(libretranslateUrl);
console.log("libretranslate url = %s", libretranslateUrl);
try {
const wModel = await settings.getWhisperModel();
setWhisperModel(wModel || "small");
} catch (err) {
console.warn(err);
}
// setWhisperModel(wModel);
setIsLoaded(true);
// console.log("Set is loaded: %s", isLoaded);
})();
// 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);
setInterval(async () => {
if (!libretranslateBaseUrl) return;
try {
const resp = await fetchWithTimeout(
libretranslateBaseUrl + "/languages",
{
method: "HEAD",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
},
5000
);
if (resp.status !== 200) {
throw new Error(resp.statusText);
}
setLangServerConn({ success: true });
} catch (err) {
setLangServerConn({
success: false,
error: `Could not connect to ${libretranslateBaseUrl}: ${err}`,
});
}
}, 1000);
setInterval(async () => {
const settings = await Settings.getDefault();
await settings.setHostLanguage(hostLanguage || "en");
await settings.setLibretranslateBaseUrl(
libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL
);
await settings.setWhisperModel(whisperModel || "small");
}, 1000);
}, []);
const doReadownload = async () => {
if (!whisperModel) return;
await initiateWhisperDownload(whisperModel, {
force_redownload: true,
onDownload: setWhisperDownloadProgress,
});
};
const doDownload = async () => {
if (!whisperModel) return;
try {
setDownloader(
await initiateWhisperDownload(whisperModel, {
onDownload: setWhisperDownloadProgress,
})
);
await downloader?.downloadAsync();
console.log("completed download");
} catch (err) {
console.error(err);
}
};
const handleHostLanguageChange = async (value: string) => {
setHostLanguage(value);
// Fetch the database connection
const db = await getDb();
const settings = new Settings(db);
// Save the updated setting value
await settings.setHostLanguage(value);
};
const handleLibretranslateBaseUrlChange = async (value: string) => {
setLibretranslateBaseUrl(value);
const settings = await Settings.getDefault();
// Save the updated setting value
await settings.setLibretranslateBaseUrl(value);
await fillHostLanguageOptions();
};
return isLoaded ? (
<View style={styles.container}>
<Text style={styles.label}>Host Language:</Text>
<Picker
selectedValue={hostLanguage || ""}
style={{ height: 50, width: "100%" }}
onValueChange={handleHostLanguageChange}
accessibilityHint="hostLanguage"
>
{languages &&
Object.entries(languages).map((lang) => (
<Picker.Item key={lang[0]} label={lang[1].name} value={lang[0]} />
))}
</Picker>
<Text style={styles.label}>LibreTranslate Base URL:</Text>
<TextInput
style={styles.input}
value={libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL}
onChangeText={handleLibretranslateBaseUrlChange}
accessibilityHint="libretranslate base url"
/>
{langServerConn &&
(langServerConn.success ? (
<Text>Success connecting to {libretranslateBaseUrl}</Text>
) : (
<Text>
Error connecting to {libretranslateBaseUrl}: {langServerConn.error}
</Text>
))}
<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>
<View>
{ /* If there's a downloader, that means we're in the middle of a download */}
{downloader && whisperDownloadProgress && (
<Text>
{whisperDownloadProgress.totalBytesWritten} of {whisperDownloadProgress.totalBytesExpectedToWrite}
</Text>
)
}
<Pressable onPress={doDownload} style={styles.button}>
<Text style={styles.buttonText}>Download</Text>
</Pressable>
</View>
</View>
) : (
<View>
<Text>Loading ...</Text>
</View>
);
};
// Create styles for the component
const styles = StyleSheet.create({
button: {
backgroundColor: "blue",
flexDirection: "row",
display: "flex",
flexShrink: 1,
padding: 20,
alignItems: "center",
alignContent: "center",
},
buttonText: {
color: "white",
alignSelf: "center",
},
container: {
flex: 1,
backgroundColor: "#fff",
justifyContent: "center",
padding: "1.5%",
padding: 20,
},
label: {
fontSize: 16,
marginBottom: 8,
},
input: {
height: 40,
borderColor: "gray",
borderWidth: 1,
marginBottom: 20,
paddingHorizontal: 8,
},
});
// Retrieve a conf item or return the default
const confGet = (key: string, def: string): string => configData[key] || def;
// Store a conf item
const confSet = (key: string, value: string): void => {
configData[key] = value;
};
// Choose from a list item
const intelligence: Record<string, string> = {
L: "Low",
M: "Medium",
H: "High",
};
export default function Settings() {
// Simply pass the schema here
// It integrates in your existing `NavigationContainer` or `Screen`
const [translator, setTranslator] = useState<Translator>(new Translator("en"))
const [languages, setLanguages] = useState<language_matrix | undefined>();
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
// This is the configuration schema
const settings: SettingsElement[] = [
{
label: "LibreTranslate server",
type: "string",
get: confGet.bind(null, "@ltServer", "http://localhost:5000"),
set: confSet.bind(null, "@ltServer"),
},
{
label: "Host Language",
type: "enum",
// You can override the way the value is displayed
values: ["en", "es", "fr"],
display: (v : string) => {
return longLang(v);
},
get: confGet.bind(null, "@hostLanguage", ""),
set: confSet.bind(null, "@hostLanguage"),
},
];
useEffect(() => {
const fetchData = async () => {
try {
// Replace with your actual async data fetching logic
const languages = await translator.fetchLanguages();
setLanguages(languages);
setLanguagesLoaded(true);
} catch (error) {
console.error("Error fetching data:", error);
}
};
});
return (
<NavigationContainer>
<View style={styles.container}>
<ReactNativeSettings settings={settings} />
</View>
</NavigationContainer>
);
}
export default SettingsComponent;

42
components/TTNavStack.tsx Normal file
View File

@ -0,0 +1,42 @@
import * as React from "react";
import SettingsComponent from "@/components/Settings";
import { LanguageSelection } from "@/components/LanguageSelection";
import {
createNativeStackNavigator,
NativeStackNavigationProp,
} from "@react-navigation/native-stack";
import { useNavigation } from "@react-navigation/native";
import ConversationThread from "@/components/ConversationThread";
import { language_matrix_entry, Translator } from "@/app/i18n/api";
import { Conversation } from "@/app/lib/conversation";
import { Settings } from "@/app/lib/settings";
import { RootStackParamList } from "@/navigation.types";
const Stack = createNativeStackNavigator();
export default function TTNavStack() {
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, "Conversation">>()
async function onLangSelected(lang: language_matrix_entry) {
const settings = await Settings.getDefault();
const hostLanguage = await settings.getHostLanguage();
const conversation = new Conversation(
await Translator.getDefault(lang.code),
{ id: "host", language: hostLanguage },
{ id: "guest", language: lang.code }
);
nav.navigate("ConversationThread", { conversation });
}
return (
<Stack.Navigator initialRouteName="LanguageSelection">
<Stack.Screen name="LanguageSelection">
{(props) => (
<LanguageSelection {...props} onLangSelected={onLangSelected} />
)}
</Stack.Screen>
<Stack.Screen name="ConversationThread" component={ConversationThread} />
<Stack.Screen name="Settings" component={SettingsComponent} />
</Stack.Navigator>
);
}

View File

@ -0,0 +1,61 @@
jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts"));
import { renderRouter} from 'expo-router/testing-library';
import React from "react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react-native";
import {
NavigationContainer,
createNavigationContainerRef,
} from "@react-navigation/native";
import TTNavStack from "../TTNavStack";
describe("Navigation", () => {
beforeEach(() => {
// Reset the navigation state before each test
jest.clearAllMocks();
jest.useFakeTimers();
});
it("Navigates to ConversationThread on language selection", async () => {
const MockComponent = jest.fn(() => <TTNavStack />);
renderRouter(
{
index: MockComponent,
},
{
initialUrl: '/',
}
);
const languageSelectionText = await waitFor(() =>
screen.getByText(/.*I Speak French.*/i)
);
act(() => {
fireEvent.press(languageSelectionText);
});
expect(await screen.findByText("Conversation Thread")).toBeOnTheScreen();
});
it("Navigates to Settings on settings selection", async () => {
const MockComponent = jest.fn(() => <TTNavStack />);
renderRouter(
{
index: MockComponent,
},
{
initialUrl: '/',
}
);
const settingsButton = await waitFor(() =>
screen.getByText(/.*Settings.*/i)
);
fireEvent.press(settingsButton);
expect(await waitFor(() => screen.getByText(/Settings/i))).toBeOnTheScreen();
// expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy()
expect(screen.getByText("Settings")).toBeOnTheScreen();
});
});

View File

@ -1,121 +1,142 @@
// import AsyncStorage from '@react-native-async-storage/async-storage';
import { CachedTranslator, Translator, language_matrix_entry } from "@/app/i18n/api"
import { longLang } from "@/app/i18n/lang"
import React, { useEffect, useRef, useState } from "react"
import { Button, Image, ImageBackground, Pressable, StyleSheet, TouchableOpacity, View } from "react-native"
import { Text } from 'react-native';
import {
CachedTranslator,
Translator,
language_matrix_entry,
} from "@/app/i18n/api";
import { longLang } from "@/app/i18n/lang";
import React, { useEffect, useRef, useState } from "react";
import {
Button,
Image,
ImageBackground,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Text } from "react-native";
import CountryFlag from "react-native-country-flag";
import { chooseCountry } from '@/app/i18n/countries';
import { chooseCountry } from "@/app/i18n/countries";
type ISpeakButtonProps = {
language: language_matrix_entry,
translator?: Translator,
onLangSelected?: (lang : language_matrix_entry) => any | Promise<any>,
language: language_matrix_entry;
translator?: Translator;
onLangSelected?: (lang: language_matrix_entry) => any | Promise<any>;
};
function iSpeak(language: language_matrix_entry) {
return `I speak ${language.name}.`;
}
function iSpeak(language : language_matrix_entry) {
return `I speak ${language.name}.`
}
async function iSpeakTr(translator : CachedTranslator, targetLang : language_matrix_entry) {
const sourceStr = iSpeak(targetLang)
return await translator.translate(sourceStr, targetLang.code);
async function iSpeakTr(
translator: CachedTranslator,
targetLang: language_matrix_entry
) {
const sourceStr = iSpeak(targetLang);
return await translator.translate(sourceStr, targetLang.code);
}
const DEFAULT_FLAGS = {
"en": ["us", "gb"],
// "sq": ["al"],
"ar": ["ae"],
"es": ["es"],
"pt": ["pt"],
"ru": ["ru"],
"it": ["it"],
"ir": ["ie"],
"sk": ["sk"],
"ro": ["ro"],
"ja": ["jp"],
"ko": ["kp", "kr"],
"el": ["gr"],
"fr": ["fr"],
"de": ["de"],
"nl": ["nl"],
"cz": ["cz"],
"uk": ["ua"],
"he": ["il"],
"hi": ["in"],
"gl": ["es"],
"fa": ["ir"],
"ur": ["pk"],
"ga": ["ie"],
"eo": ["es"]
}
en: ["us", "gb"],
// "sq": ["al"],
ar: ["ae"],
es: ["es"],
pt: ["pt"],
ru: ["ru"],
it: ["it"],
ir: ["ie"],
sk: ["sk"],
ro: ["ro"],
ja: ["jp"],
ko: ["kp", "kr"],
el: ["gr"],
fr: ["fr"],
de: ["de"],
nl: ["nl"],
cz: ["cz"],
uk: ["ua"],
he: ["il"],
hi: ["in"],
gl: ["es"],
fa: ["ir"],
ur: ["pk"],
ga: ["ie"],
eo: ["es"],
};
const ISpeakButton = (props : ISpeakButtonProps) => {
const ISpeakButton = (props: ISpeakButtonProps) => {
const [title, setTitle] = useState<string | undefined>();
const [titleLoaded, setTitleLoaded] = useState<boolean>(false);
const [translator, setTranslator] = useState<Translator | undefined>(
undefined
);
const [title, setTitle] = useState<string | undefined>();
const [titleLoaded, setTitleLoaded] = useState<boolean>(false);
const translator = props.translator || new CachedTranslator("en");
useEffect(() => {
(async function () {
const tr = props.translator || (await CachedTranslator.getDefault());
if (!tr) {
console.error("Failed to construct cachedTranslator");
}
setTranslator(tr);
try {
// Replace with your actual async data fetching logic
const title2 = await iSpeakTr(tr, props.language);
setTitle(title2);
} catch (error) {
console.error("Error fetching data from %s: %s", tr.languageServer.baseUrl, error);
} finally {
setTitleLoaded(true);
}
})();
}, []);
useEffect(() => {
const fetchData = async () => {
try {
// Replace with your actual async data fetching logic
const title = await iSpeakTr(translator, props.language);
setTitle(title);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setTitleLoaded(true);
}
};
fetchData();
}, []);
const countries = DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
return (
title ? (
<TouchableOpacity style={styles.button} onPress={() => props.onLangSelected && props.onLangSelected(props.language)}>
<View>
<View style={styles.flag}>
{countries &&
countries.map( c => {
return <CountryFlag isoCode={c} size={25} key={c}/> }
)
}
</View>
<View style={styles.iSpeak}>
<Text style={styles.iSpeakText}>{ title }</Text>
</View>
</View>
</TouchableOpacity>
) : (
<Text>Loading...</Text>
)
)
const countries =
// @ts-ignore
DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
}
return title ? (
<TouchableOpacity
style={styles.button}
onPress={() =>
props.onLangSelected && props.onLangSelected(props.language)
}
>
<View>
<View style={styles.flag}>
{countries &&
countries.map((c) => {
return <CountryFlag isoCode={c} size={25} key={c} />;
})}
</View>
<View>
<Text style={styles.iSpeakText}>{title}</Text>
</View>
</View>
</TouchableOpacity>
) : (
<Text>Loading...</Text>
);
};
const styles = StyleSheet.create({
button: {
width: "20%",
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
height: 110,
alignSelf: "flex-start",
margin: 8,
},
flag: {
},
iSpeak: {
textAlign: "center",
},
iSpeakText: {
textAlign: "center"
}
})
button: {
width: "20%",
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
height: 110,
alignSelf: "flex-start",
margin: 8,
},
flag: {},
iSpeak: {
textAlign: "center",
},
iSpeakText: {
textAlign: "center",
},
});
export default ISpeakButton;
export default ISpeakButton;

View File

@ -9,58 +9,35 @@ type MessageProps = {
}
const MessageBubble = (props: MessageProps) => {
const [text, setText] = useState(props.message.text);
const [translatedText, setTranslatedText] = useState<string|undefined>();
const [isTranslating, setIsTranslating] = useState<boolean>(false);
useEffect(() => {
props.message.onTextUpdate = (message: Message) => {
setText(message.text);
}
props.message.onTextDone = async (message: Message) => {
setIsTranslating(true);
await props.message.translate()
}
props.message.onTranslationDone = (message: Message) => {
if (!message.translation) throw new Error("Missing translation");
setTranslatedText(message.translation);
setIsTranslating(false);
}
}, [props.message])
const spId = props.message.speaker.id
return (
<SafeAreaView>
{text && (
<Text>{text}</Text>
{props.message.text && (
<Text>{props.message.text}</Text>
)}
{translatedText &&
<Text>{translatedText}</Text>
{props.message.translation &&
<Text accessibilityHint="translation">{props.message.translation}</Text>
}
</SafeAreaView>
)
}
// const bubbleStyle = StyleSheet.create({
// host: {
const bubbleStyle = StyleSheet.create({
host: {
// },
// guest: {
},
guest: {
// },
// })
},
})
// const textStyles = StyleSheet.create({
// native: {
const textStyles = StyleSheet.create({
native: {
// },
// translation: {
},
translation: {
// },
// });
},
});
export default MessageBubble;

View File

View File

@ -0,0 +1,33 @@
import { Settings } from "@/app/lib/settings"
import { WHISPER_MODELS } from "@/app/lib/whisper"
import { Picker } from "@react-native-picker/picker"
import { useEffect, useState } from "react"
import { Pressable, View } from "react-native"
const WhisperDownloader = () => {
const [whisperModel, setWhisperModel] = useState<string|undefined>();
useEffect(() => {
const settings = await Settings.getDefault();
setWhisperModel((await settings.getWhisperModel()) || "small");
}, [])
return (
<View>
<Picker
selectedValue={whisperModel || ""}
style={{ height: 50, width: "100%" }}
onValueChange={setWhisperModel}
accessibilityHint="whisper model"
>
{Object.entries(WHISPER_MODELS).map(([key, { label }]) => (
<Picker.Item key={key} label={label} value={key} />
))}
</Picker>
<WhisperDownloadButton whisperModel={whisperModel} />
<WhisperDownloadInfo whisperModel={whisperModel} />
</View>
}

View File

@ -1,12 +1,59 @@
import React, { act } from 'react';
import { render, screen } from '@testing-library/react-native'
import { render, screen, waitFor } from '@testing-library/react-native'
import MessageBubble from '@/components/ui/MessageBubble';
import { Conversation, Speaker } from '@/app/lib/conversation';
import {Translator} from '@/app/i18n/api';
import {LanguageServer, Translator, language_matrix} from '@/app/i18n/api';
import { View } from 'react-native';
import { LIBRETRANSLATE_BASE_URL } from '@/constants/api';
jest.mock("@/app/i18n/api", () => {
class LanguageServer {
fetchLanguages = () => {
return {
"en": {
code: "en",
name: "English",
targets: [
"fr",
"es"
]
},
"fr": {
code: "fr",
name: "French",
targets: [
"en",
"es"
]
},
"es": {
code: "es",
name: "Spanish",
targets: [
"en",
"fr"
]
},
} as language_matrix
}
}
class Translator {
translate = jest.fn((text : string, target : string) => {
if (text.match(/Hello, how are you\?/i)) {
return "Hola, ¿cómo estás?"
}
return "??? Huh ???"
})
}
return {
LanguageServer,
Translator,
}
})
describe('Message Component', () => {
const translator = new Translator('en', 'es');
const translator = new Translator('en', 'es', new LanguageServer(LIBRETRANSLATE_BASE_URL));
const host : Speaker = {id : "host", language : "en"}
const guest : Speaker = {id : "guest", language: "es"}
@ -21,8 +68,8 @@ describe('Message Component', () => {
it('renders the message text correctly', async () => {
conversation.addMessage(host, "Hello, World!");
const message = conversation[0];
render(<View></View>);
// render(<MessageBubble message={message} />);
// render(<View></View>);
render(<MessageBubble message={message} />);
expect(await screen.findByText(message.text as string)).toBeOnTheScreen();
});
@ -32,7 +79,7 @@ describe('Message Component', () => {
await conversation.translateLast();
render(<MessageBubble message={conversation[0]} />);
expect(await screen.findByText(translatedText)).toBeOnTheScreen();
expect(screen.getByAccessibilityHint("translation")).toBeOnTheScreen();
});
it('widget still renders pre-translation', async () => {
@ -42,10 +89,8 @@ describe('Message Component', () => {
render(<MessageBubble message={conversation[0]} />);
expect(screen.getByText(text)).toBeOnTheScreen();
// expect(screen.getByText(translatedText)).not.toBeOnTheScreen();
await act(async () => {
await conversation.translateLast();
});
expect(await screen.findByText(text)).toBeOnTheScreen();
expect(await screen.findByText(translatedText)).toBeOnTheScreen();
// await conversation.translateLast();
// expect(await screen.findByText(text)).toBeOnTheScreen();
// expect(await screen.findByText(translatedText)).toBeOnTheScreen();
});
});

View File

@ -0,0 +1,110 @@
import React, { Dispatch } from "react";
import { render, screen, fireEvent, act } from "@testing-library/react-native";
import SettingsComponent from "@/components/Settings";
import { language_matrix } from "@/app/i18n/api";
import { Settings } from "@/app/lib/settings";
import { getDb, migrateDb } from "@/app/lib/db";
const RENDER_TIME = 1000;
jest.mock("@/app/i18n/api", () => {
class LanguageServer {
fetchLanguages = () => {
return {
"en": {
code: "en",
name: "English",
targets: [
"fr",
"es"
]
},
"fr": {
code: "fr",
name: "French",
targets: [
"en",
"es"
]
},
"es": {
code: "es",
name: "Spanish",
targets: [
"en",
"fr"
]
},
} as language_matrix
}
}
class Translator {
translate = jest.fn((text : string, target : string) => {
return "Hola, como estas?"
})
}
return {
LanguageServer,
Translator,
}
})
describe("SettingsComponent", () => {
beforeEach(async() => {
await migrateDb();
const settings = await Settings.getDefault();
await settings.setHostLanguage("en");
await settings.setLibretranslateBaseUrl("https://example.com");
})
beforeAll(() => {
jest.useFakeTimers();
})
afterAll(() => {
jest.useRealTimers()
})
test("renders correctly with initial settings", async () => {
render(<SettingsComponent />);
jest.advanceTimersByTime(RENDER_TIME);
screen.debug();
// Wait for the component to fetch and display the initial settings
await screen.findByText(/Host Language:/i);
await screen.findByText(/LibreTranslate Base URL:/i);
// expect(screen.getByDisplayValue("English")).toBeTruthy();
expect(screen.getByAccessibilityHint("libretranslate base url")).toBeTruthy();
});
test("updates host language setting when input changes", async () => {
render(<SettingsComponent />);
// Wait for the component to fetch and display the initial settings
await screen.findByText(/Host Language:/i);
await screen.findByText(/LibreTranslate Base URL:/i);
// Change the host language input value
const picker = screen.getByAccessibilityHint("hostLanguage");
fireEvent(picker, "onvalueChange", "es");
expect(picker.props.selectedIndex).toStrictEqual(0);
});
test("updates LibreTranslate base URL setting when input changes", async () => {
render(<SettingsComponent />);
jest.advanceTimersByTime(RENDER_TIME)
screen.debug();
// Wait for the component to fetch and display the initial settings
await screen.findByText(/Host Language:/i);
await screen.findByText(/LibreTranslate Base URL:/i);
// Change the LibreTranslate base URL input value
fireEvent.changeText(screen.getByAccessibilityHint("libretranslate base url"), "http://new-example.com");
jest.advanceTimersByTime(RENDER_TIME);
expect(screen.getByAccessibilityHint("libretranslate base url")).toBeTruthy();
});
});

View File

@ -1,24 +1,71 @@
// jestSetup.ts
jest.mock('expo-sqlite', () => {
return {
openDatabaseAsync: async (name: string) => {
const {DatabaseSync} = require("node:sqlite")
const db = new DatabaseSync(':memory:');
// include this line for mocking react-native-gesture-handler
import 'react-native-gesture-handler/jestSetup';
return {
closeAsync: jest.fn(() => db.close()),
executeSql: jest.fn((sql: string) => db.exec(sql)),
runAsync: jest.fn(async (sql: string, params = []) => {
const stmt = db.prepare(sql);
stmt.run(params)
}),
getFirstAsync: jest.fn(async (sql : string, params = []) => {
const stmt = db.prepare(sql)
const result = stmt.run(params);
return stmt.all(params)[0]
})
};
},
jest.mock("expo-sqlite", () => {
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations");
const openDatabaseAsync = async (name: string) => {
return {
closeAsync: jest.fn(() => db.close()),
executeSql: jest.fn((sql: string) => db.exec(sql)),
runAsync: jest.fn(async (sql: string, params = []) => {
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
const s = db.prepare(stmt);
s.run();
}
}
const stmt = db.prepare(sql);
// console.log("Running %s with %s", sql, params);
try {
stmt.run(params);
} catch (e) {
throw new Error(
`running ${sql} with params ${JSON.stringify(params)}: ${e}`
);
}
}),
getFirstAsync: jest.fn(async (sql: string, params = []) => {
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
const s = db.prepare(stmt);
s.run();
}
}
const stmt = db.prepare(sql);
// const result = stmt.run(...params);
return stmt.get(params);
}),
};
});
};
return {
migrateDb: async (direction: "up" | "down" = "up") => {
const db = await openDatabaseAsync("translation_terrace");
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
await db.executeSql(stmt);
}
}
},
openDatabaseAsync,
};
});
// include this section and the NativeAnimatedHelper section for mocking react-native-reanimated
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
// The mock for `call` immediately calls the callback which is incorrect
// So we override it with a no-op
Reanimated.default.call = () => {};
return Reanimated;
});
// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
// jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

11
navigation.types.ts Normal file
View File

@ -0,0 +1,11 @@
// navigation.types.ts
import { ParamListBase } from '@react-navigation/native';
import { Conversation } from '@/app/lib/conversation';
export type RootStackParamList = {
LanguageSelection: undefined;
ConversationThread: undefined;
Settings: undefined;
Conversation: { conversation?: Conversation };
};

316
package-lock.json generated
View File

@ -12,17 +12,22 @@
"@expo/vector-icons": "^14.0.4",
"@mmomtchev/react-native-settings": "^1.1.0",
"@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",
@ -48,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",
@ -2518,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",
@ -2928,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",
@ -3387,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",
@ -4045,6 +4059,19 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.0.tgz",
"integrity": "sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==",
"license": "MIT",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.76.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz",
@ -4682,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",
@ -5056,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",
@ -5063,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",
@ -5604,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",
@ -5883,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",
@ -7050,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",
@ -7716,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",
@ -7733,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",
@ -7745,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": {
@ -7758,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",
@ -7927,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",
@ -7990,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",
@ -8068,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",
@ -9322,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",
@ -12338,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",
@ -15722,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

@ -4,7 +4,7 @@
"version": "1.0.0",
"scripts": {
"test": "jest --watch --coverage=false --changedSince=origin/main",
"testDebug": "jest -o --watch --coverage=false",
"testDebug": "jest -o --watch --bail --coverage=false",
"testFinal": "jest",
"updateSnapshots": "jest -u --coverage=false",
"start": "expo start",
@ -19,17 +19,22 @@
"@expo/vector-icons": "^14.0.4",
"@mmomtchev/react-native-settings": "^1.1.0",
"@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",
@ -53,32 +58,28 @@
},
"jest": {
"preset": "jest-expo",
"testPathIgnorePatterns": [
".ollama"
],
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
],
"collectCoverage": true,
"collectCoverageFrom": [
"**/*.{ts,tsx,js,jsx}",
"!**/coverage/**",
"!**/node_modules/**",
"!**/babel.config.js",
"!**/expo-env.d.ts",
"!**/.expo/**"
],
"automock": false,
"setupFilesAfterEnv": [
"<rootDir>/jestSetup.ts"
],
"testTimeout": 10000
]
},
"devDependencies": {
"@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",