Compare commits
10 Commits
52bb6f812c
...
4549442bd8
Author | SHA1 | Date | |
---|---|---|---|
|
4549442bd8 | ||
|
87446784ae | ||
|
6f941c56d1 | ||
|
c3d543be39 | ||
|
eb7599bfe8 | ||
|
6673663883 | ||
|
b3c2e09987 | ||
|
bc3d481d25 | ||
|
081ac367ba | ||
|
68cc052417 |
39
.ollama/ExampleComponent.tsx
Normal file
39
.ollama/ExampleComponent.tsx
Normal 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;
|
37
.ollama/ExampleTest.spec.tsx
Normal file
37
.ollama/ExampleTest.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -1,3 +1,2 @@
|
||||
{
|
||||
"jestTestExplorer.pathToJest": "./node_modules/.bin/jest"
|
||||
}
|
44
__mocks__/api.ts
Normal file
44
__mocks__/api.ts
Normal 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
10
__mocks__/db.ts
Normal 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 [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
4
__mocks__/expo-file-system/next.js
Normal file
4
__mocks__/expo-file-system/next.js
Normal file
@ -0,0 +1,4 @@
|
||||
export const File = jest.fn();
|
||||
export const Paths = {
|
||||
join: jest.fn(),
|
||||
};
|
@ -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
16
__mocks__/settings.ts
Normal 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,
|
||||
};
|
@ -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>
|
||||
|
6
app.json
6
app.json
@ -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"
|
||||
]
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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));
|
||||
}
|
||||
|
@ -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
23
app/lib/migrations.ts
Normal 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`],
|
||||
};
|
@ -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
213
app/lib/whisper.ts
Normal 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
36
app/service/download.ts
Normal 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),
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
42
components/TTNavStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
components/__tests__/index.spec.tsx
Normal file
61
components/__tests__/index.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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;
|
0
components/ui/WhisperDownloadButton.tsx
Normal file
0
components/ui/WhisperDownloadButton.tsx
Normal file
33
components/ui/WhisperDownloader.tsx
Normal file
33
components/ui/WhisperDownloader.tsx
Normal 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>
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
110
components/ui/__tests__/Settings.spec.tsx
Normal file
110
components/ui/__tests__/Settings.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
87
jestSetup.ts
87
jestSetup.ts
@ -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
11
navigation.types.ts
Normal 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
316
package-lock.json
generated
@ -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",
|
||||
|
29
package.json
29
package.json
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user