Time units, some fixes

This commit is contained in:
xavis 2025-07-30 14:10:23 +02:00
parent db495f6f1e
commit be2335e3bc
22 changed files with 381 additions and 103 deletions

View File

@ -92,8 +92,8 @@ android {
applicationId 'com.xaviscript.taskeep'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
versionCode 4
versionName "1.1.0"
}
signingConfigs {
release {

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -1 +1,3 @@
<resources/>
<resources>
<color name="splashscreen_background">#232323</color>
</resources>

View File

@ -58,10 +58,10 @@ expo.useLegacyPackaging=false
android.packagingOptions.pickFirsts=**/libc++_shared.so
expo.sqlite.enableFTS=false
expo.sqlite.useSQLCipher=false
# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
expo.edgeToEdgeEnabled=true
MYAPP_UPLOAD_STORE_FILE=taskeep.keystore
MYAPP_UPLOAD_KEY_ALIAS=taskeep
MYAPP_UPLOAD_STORE_PASSWORD=123?_Tk_?123
MYAPP_UPLOAD_KEY_PASSWORD=123?_Tk_?123
# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
expo.edgeToEdgeEnabled=true

View File

@ -3,7 +3,7 @@
"name": "Taskeep",
"main": "index.tsx",
"slug": "taskeep",
"version": "1.0.0",
"version": "1.1.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "taskeep",
@ -18,7 +18,8 @@
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.xaviscript.taskeep"
"package": "com.xaviscript.taskeep",
"versionCode": 4
},
"web": {
"bundler": "metro",
@ -33,7 +34,11 @@
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/images/splash-icon.png",
"backgroundColor": "#232323"
}
}
],
[

View File

@ -4,6 +4,7 @@ import Ionicons from '@expo/vector-icons/Ionicons';
import { useFocusEffect } from 'expo-router';
import { navigate } from 'expo-router/build/global-state/routing';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PermissionsAndroid, Platform } from 'react-native';
import { TaskItem } from '../../components/taskItem';
import { ThemedText } from '../../components/ThemedText';
@ -33,6 +34,7 @@ export default function HomeScreen() {
const [tasks, setTasks] = useState<Task[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [ready, setReady] = useState(false);
const { t } = useTranslation();
useEffect(() => { // Initial data fetch
(async () => {
@ -68,30 +70,13 @@ export default function HomeScreen() {
}
}, []);
async function schedulePushNotification() {
if (Platform.OS === 'web' || initiated.current) return;
initiated.current = true;
await Notifications.cancelAllScheduledNotificationsAsync();
await Notifications.dismissAllNotificationsAsync();
await Notifications.scheduleNotificationAsync({
content: {
title: `${t("remember_notification_title")} 🌠`,
body: t("remember_notification_body"),
},
trigger: {
hour: 22,
minute: 0,
repeats: true
},
});
}
const RefreshTasks = async () => {
try {
const fetchedTasks = await taskRepository.findAll();
fetchedTasks.sort((a, b) => {
return (a.daysToRedo! * 24 * 3600 * 1000 - (Date.now() - a.lastDone!)) - ((b.daysToRedo! * 24 * 3600 * 1000) - (Date.now() - b.lastDone!));
});
})
console.log("Tasks fetched:", fetchedTasks);

View File

@ -44,11 +44,11 @@ export default function CategoryForm() {
<EmojiPopup onEmojiSelected={emoji => setNewCategory({ ...newCategory, icon: emoji })} >
<ThemedView style={styles.addIconView}>
{newCategory.icon != "" ? <ThemedText>{newCategory.icon}</ThemedText> : <Ionicons name={newCategory.icon || "happy-outline"} size={24} color="#ccc" />}
<ThemedText>{newCategory.icon != "" ? "Change" : "Select"} icon</ThemedText>
<ThemedText>{newCategory.icon != "" ? "Change" : "Select"} icon *</ThemedText>
</ThemedView>
</EmojiPopup>
<TextInput
placeholder="Task title *"
placeholder="Category title *"
style={styles.inputText}
placeholderTextColor="#333"
value={newCategory.title}

View File

@ -14,12 +14,42 @@ import { SQLiteDataService } from '../../services/data/sqliteDataService';
const taskRepository = new TaskRepository(new SQLiteDataService<Task>(TaskQuery));
const categoryRepository = new CategoryRepository(new SQLiteDataService<Category>(CategoryQuery));
const timeUnits = [
{ label: 'Minutes', value: 'minutes' },
{ label: 'Hours', value: 'hours' },
{ label: 'Days', value: 'days' },
{ label: 'Weeks', value: 'weeks' },
{ label: 'Months', value: 'months' }
];
const timeUnitToDays = (num: number, unit: string): number => {
let result = 0;
switch (unit) { // convert to days
case 'minutes':
result = num / 1440; // Convert minutes to days
break;
case 'hours':
result = num / 24; // Convert hours to days
break;
case 'days':
result = num; // Convert days to seconds
break;
case 'weeks':
result = num * 7; // Convert weeks to days
break;
case 'months':
result = num * 30; // Convert months to days (approximation)
break;
}
return result;
}
export default function TasksForm() {
const { task: taskJson } = useLocalSearchParams();
const task: Task = taskJson ? JSON.parse(taskJson as string) : null;
const [categories, setCategories] = useState<Category[]>([]);
const [selectedTimeUnit, setSelectedTimeUnit] = useState<string>("days");
const router = useRouter();
@ -29,13 +59,35 @@ export default function TasksForm() {
category: task?.category || -1,
});
const adaptDaysToGoToUnit = () => {
if (!task) return;
if (task.daysToRedo! <= 1000 * 3600) {
setSelectedTimeUnit("minutes");
setNewTask({ ...newTask, daysToRedo: task.daysToRedo! * 60 * 24 });
}
else if (task.daysToRedo! <= 1000 * 3600 * 24) {
setSelectedTimeUnit("hours");
setNewTask({ ...newTask, daysToRedo: task.daysToRedo! * 24 });
} else if (task.daysToRedo! <= 1000 * 3600 * 24 * 7) {
setSelectedTimeUnit("days");
setNewTask({ ...newTask, daysToRedo: task.daysToRedo! });
} else if (task.daysToRedo! <= 1000 * 3600 * 24 * 30) {
setSelectedTimeUnit("weeks");
setNewTask({ ...newTask, daysToRedo: task.daysToRedo! / 7 });
} else {
setSelectedTimeUnit("months");
setNewTask({ ...newTask, daysToRedo: task.daysToRedo! / 30 });
}
}
useFocusEffect(React.useCallback(() => {
categoryRepository.findAll().then((categories) => {
categories.push(new Category("_create new_", "", 0));
categories.push(new Category("Create new category", "", 0));
setCategories(categories);
if (newTask.category === 0) {
setNewTask({ ...newTask, category: -1 });
}
adaptDaysToGoToUnit();
}).catch((error) => {
console.error("Error fetching categories:", error);
});
@ -65,14 +117,47 @@ export default function TasksForm() {
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.nativeEvent.text })}
/>
<TextInput
placeholder="Days after which task is due *"
style={styles.inputText}
placeholderTextColor="#333"
keyboardType='numeric'
value={isNaN(newTask.daysToRedo ?? NaN) ? undefined : newTask.daysToRedo?.toString()}
onChange={(e) => setNewTask({ ...newTask, daysToRedo: e.nativeEvent.text.length > 0 ? parseInt(e.nativeEvent.text) : NaN })}
/>
<View style={{ flexDirection: 'row', gap: 8, height: 60 }}>
<TextInput
placeholder={`${timeUnits.find(unit => unit.value === selectedTimeUnit)?.label} after which task is due *`}
style={styles.inputText}
placeholderTextColor="#333"
keyboardType='numeric'
value={isNaN(newTask.daysToRedo ?? NaN) ? undefined : newTask.daysToRedo?.toString()}
onChange={(e) => setNewTask({ ...newTask, daysToRedo: e.nativeEvent.text.length > 0 ? parseInt(e.nativeEvent.text) : NaN })}
/>
<Dropdown
options={timeUnits}
onValueChange={(value) => setSelectedTimeUnit(value?.toString() ?? "")}
selectedValue={selectedTimeUnit}
maxSelectableItems={1}
checkboxControls={{
checkboxLabelStyle: { fontSize: 20, paddingVertical: 10 },
checkboxStyle: {
backgroundColor: '#e744a9ff',
borderColor: 'transparent',
}
}}
dropdownIconStyle={{
alignSelf: 'flex-start',
margin: 0,
padding: 0,
marginTop: -30
}}
dropdownContainerStyle={
{
flex: 1,
marginTop: -2
}
}
dropdownStyle={{
borderColor: '#ccc',
borderWidth: 2,
backgroundColor: '#fff',
}}
/>
</View>
<Dropdown
placeholder="Select category *"
options={
@ -103,9 +188,15 @@ export default function TasksForm() {
padding: 0,
marginTop: -30
}}
dropdownStyle={{
borderColor: '#ccc',
borderWidth: 2
}}
/>
</ThemedView>
<Pressable style={styles.addButton} onPress={() => {
newTask.daysToRedo = timeUnitToDays(newTask.daysToRedo, selectedTimeUnit);
if (newTask.title.length > 0 && newTask.daysToRedo > 0 && newTask.category > -1) {
if (task && task.id) {
// Update existing task
@ -167,8 +258,9 @@ const styles = StyleSheet.create({
position: 'absolute',
},
inputText: {
flex: 1,
backgroundColor: '#fff',
height: 50,
height: 60,
color: '#333333',
borderRadius: 8,
padding: 10,

View File

@ -5,16 +5,33 @@ import { Category } from '../../models/category';
import { Task } from '../../models/task';
import { GetTaskColor } from '../../utils/colors';
const adaptDaysToGoToUnit = (t: Task) => {
const timeLeft = t.lastDone! + (t.daysToRedo! * 24 * 60 * 60 * 1000) - Date.now();
if (timeLeft < 0) return 0; // Task is overdue
else if (timeLeft < 3 * 60 * 60 * 1000) return Math.ceil(timeLeft / (60 * 1000)) + " minutes";
else if (timeLeft < 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (3600 * 1000)) + " hours";
else if (timeLeft < 7 * 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (24 * 3600 * 1000)) + " days";
else if (timeLeft < 30 * 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (7 * 24 * 3600 * 1000)) + " weeks";
else return Math.ceil(timeLeft / (30 * 24 * 3600 * 1000)) + " months";
};
export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?: Category[] }) {
const demoTasks: Task[] = [
new Task('Task 1', 3, 1, new Date().getTime()),
new Task('Task 2', 5, 1, new Date().getTime()),
new Task('Task 2', 5, 1, new Date().getTime()),
new Task('Task 2', 5, 1, new Date().getTime()),
new Task('Task 2', 5, 1, new Date().getTime())
new Task('Task 1', 1, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 0, 5), // 2 days ago
new Task('Task 2', 2, 1, new Date().getTime() - 1000 * 60 * 60 * 24), // 1 day ago
new Task('Task 3', 3, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 2), // 2 days ago
new Task('Task 4', 4, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 3), // 3 days ago
new Task('Task 5', 5, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 4), // 4 days ago
new Task('Task 6', 6, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 10), // 4 days ago
];
tasks = (tasks ?? demoTasks)?.sort((a, b) => {
const aExpiration = (Date.now() - a.lastDone!) / (a.daysToRedo! * 24 * 60 * 60 * 1000);
const bExpiration = (Date.now() - b.lastDone!) / (b.daysToRedo! * 24 * 60 * 60 * 1000);
return aExpiration - bExpiration;
}).reverse();
return (
<FlexWidget
style={{
@ -50,61 +67,75 @@ export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?:
height: 'match_parent',
}}
>
{(tasks ?? demoTasks).map((task, index) => (
<FlexWidget
key={Date.now() + "-" + Math.random() + "-" + index + "-" + task.id}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: 'match_parent',
paddingVertical: 8
}}>
{tasks.map((task, index) => {
const category = categories?.find(c => c.id === task.category);
const timeLeft = adaptDaysToGoToUnit(task)
const circleDegree = parseInt(timeLeft.toString()) > 0 ? ((task.daysToRedo! - (((Date.now() - task.lastDone!) / (24 * 3600 * 1000)))) / task.daysToRedo!) * 360 : 0;
return (
<FlexWidget
key={Date.now() + "-" + Math.random() + "-" + index + "-" + task.id}
style={{
width: 'match_parent',
borderLeftWidth: 20,
borderLeftColor: GetTaskColor(task) as ColorProp,
alignItems: 'center',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#333333',
}}
>
width: 'match_parent',
paddingVertical: 8
}}>
<FlexWidget
style={{
flexDirection: 'column'
}}>
<TextWidget
text={task.title}
width: 'match_parent',
borderLeftWidth: 20,
borderLeftColor: GetTaskColor(task) as ColorProp,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#333333',
}}
>
<FlexWidget
style={{
color: '#ffffff',
fontSize: 20,
fontWeight: 'bold',
}}
/>
<TextWidget
text={categories?.find(c => c.id === task.category)?.title || 'Uncategorized'}
style={{
color: '#ffffff',
fontSize: 16,
}}
/>
flexDirection: 'column'
}}>
<TextWidget
text={task.title}
style={{
color: '#ffffff',
fontSize: 20,
fontWeight: 'bold',
}}
/>
<TextWidget
text={`${category ? (category.icon + " " + category.title) : 'Uncategorized'}`}
style={{
color: '#ffffff',
fontSize: 12,
}}
/>
<TextWidget
text={parseInt(timeLeft.toString()) > 0 ? timeLeft + " left" : "Overdue"}
style={{
color: '#ffffff',
fontSize: 16,
}}
/>
</FlexWidget>
<SvgWidget svg={getClockProgressSVG(
circleDegree,
100,
100,
GetTaskColor(task) as ColorProp
)} clickAction='UPDATE_TASK' clickActionData={{
id: task.id
}} />
</FlexWidget>
<SvgWidget svg={getClockProgressSVG(
((task.daysToRedo! - (((Date.now() - task.lastDone!) / (24 * 3600 * 1000)))) / task.daysToRedo!) * 360, // Example calculation for degree
100,
100,
GetTaskColor(task) as ColorProp
)} clickAction='UPDATE_TASK' clickActionData={{
id: task.id
}} />
</FlexWidget>
</FlexWidget>
))}
)
})}
{(tasks && tasks.length === 0) &&
<TextWidget text='No tasks created.' style={{
color: '#ffffff'

View File

@ -6,6 +6,7 @@ import { Task, TaskQuery } from '../../models/task';
import { CategoryRepository } from '../../repositories/CategoryRepository';
import { TaskRepository } from '../../repositories/TaskRepository';
import { SQLiteDataService } from '../../services/data/sqliteDataService';
import { throttle } from '../../utils/throttle';
import { HelloWidget } from './hello';
@ -50,9 +51,9 @@ notify_listener = Notifications.addNotificationReceivedListener(notification =>
notify_responseListener = Notifications.addNotificationResponseReceivedListener(response => { });
const CheckTaskAndNotify = async (): Promise<{ tasks: Task[], categories: Category[] }> => {
tasks = await taskRepository.findAll();
categories = await categoryRepository.findAll();
const CheckTaskAndNotify = async (): Promise<{ newTasks: Task[], newCategories: Category[] }> => {
const tasks = await taskRepository.findAll();
const categories = await categoryRepository.findAll();
const expiredTasks = tasks.filter(task => {
const expirationDate = task.lastDone! + (task.daysToRedo! * 24 * 60 * 60 * 1000); // Convert days to milliseconds
@ -74,19 +75,30 @@ const CheckTaskAndNotify = async (): Promise<{ tasks: Task[], categories: Catego
}
}
return { tasks, categories };
return { newTasks: tasks, newCategories: categories };
}
const throttledCheckTaskAndNotify = throttle(CheckTaskAndNotify, 1000); // Throttle to prevent too frequent calls
setInterval(async () => {
CheckTaskAndNotify();
const { newTasks, newCategories } = await throttledCheckTaskAndNotify();
console.log("Throttled task check and notify executed OUT");
console.log("New tasks:", newTasks);
console.log("New categories:", newCategories);
tasks = newTasks;
categories = newCategories;
}, 1000 * 60 * 30); // Check every 30 minutes
export async function widgetTaskHandler(props: WidgetTaskHandlerProps) {
async function updateAndRenderWidget() {
const { tasks, categories } = await CheckTaskAndNotify();
const { newTasks, newCategories } = await throttledCheckTaskAndNotify();
tasks = newTasks;
categories = newCategories;
console.log("Throttled task check and notify executed IN");
console.log("New tasks:", newTasks);
console.log("New categories:", newCategories);
props.renderWidget(<HelloWidget tasks={tasks} categories={categories} />);
}

View File

@ -23,6 +23,16 @@ export type TaskItemProps = ViewProps & {
const taskRepository = new TaskRepository(new SQLiteDataService<Task>(TaskQuery));
const adaptDaysToGoToUnit = (t: Task) => {
const timeLeft = t.lastDone! + (t.daysToRedo! * 24 * 60 * 60 * 1000) - Date.now();
if (timeLeft < 0) return 0; // Task is overdue
else if (timeLeft < 3 * 60 * 60 * 1000) return Math.ceil(timeLeft / (60 * 1000)) + " minutes";
else if (timeLeft < 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (3600 * 1000)) + " hours";
else if (timeLeft < 7 * 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (24 * 3600 * 1000)) + " days";
else if (timeLeft < 30 * 24 * 60 * 60 * 1000) return Math.ceil(timeLeft / (7 * 24 * 3600 * 1000)) + " weeks";
else return Math.ceil(timeLeft / (30 * 24 * 3600 * 1000)) + " months";
};
export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ...otherProps }: TaskItemProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
const router = useRouter();
@ -79,7 +89,7 @@ export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ..
{task?.title}
</Text>
<Text style={styles(colorScheme).taskItemText}>
{category?.icon + " " + category?.title + " - " + Math.ceil(daysLeft.current / (3600 * 1000))} hours left
{category?.icon + " " + category?.title + " - " + (adaptDaysToGoToUnit(task) == 0 ? "Overdue" : adaptDaysToGoToUnit(task))}
</Text>
</ThemedView>
<Pressable style={styles(colorScheme).checkButton} onPress={() => {
@ -92,7 +102,7 @@ export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ..
console.error("Error updating task:", error);
});
}}>
<ClockProgress degree={((daysLeft.current / (24 * 3600 * 1000)) / task.daysToRedo!) * 360} width={40} height={40} color={GetTaskColor(task!)} />
<ClockProgress degree={parseInt(adaptDaysToGoToUnit(task).toString()) > 0 ? ((daysLeft.current / (24 * 3600 * 1000)) / task.daysToRedo!) * 360 : 0} width={40} height={40} color={GetTaskColor(task!)} />
</Pressable>
</ThemedView></Pressable>;
}

23
i18n.js Normal file
View File

@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import * as RNLocalize from 'react-native-localize';
import en from './translations/en.json';
const resources = {
en: { translation: en },
};
const fallback = { languageTag: 'en', isRTL: false };
const { languageTag } = RNLocalize.findBestLanguageTag(Object.keys(resources)) || fallback;
i18n
.use(initReactI18next)
.init({
resources,
lng: languageTag,
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export default i18n;

View File

@ -4,4 +4,5 @@ import { widgetTaskHandler } from './app/widgets/widget-task-handler';
registerWidgetTaskHandler(widgetTaskHandler);
import 'expo-router/entry';
import './i18n.js';

100
package-lock.json generated
View File

@ -32,13 +32,16 @@
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.10",
"expo-web-browser": "~14.2.0",
"i18next": "^25.3.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.6.1",
"react-native": "0.79.5",
"react-native-android-widget": "^0.17.0",
"react-native-emoji-popup": "^0.3.2",
"react-native-gesture-handler": "~2.24.0",
"react-native-input-select": "^2.1.7",
"react-native-localize": "^3.5.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
@ -8491,6 +8494,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -8545,6 +8557,37 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/i18next": {
"version": "25.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz",
"integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -11870,6 +11913,32 @@
"react": ">=17.0.0"
}
},
"node_modules/react-i18next": {
"version": "15.6.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.1.tgz",
"integrity": "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
@ -12009,6 +12078,26 @@
"react-native": "*"
}
},
"node_modules/react-native-localize": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/react-native-localize/-/react-native-localize-3.5.1.tgz",
"integrity": "sha512-AX2soRR1/BCK2GvzjIpH+0EQaR2acfojFtjAstnYBFPR7ThIQx5Xb+OlqpzYxcYTPDxIcU5i4/yMa23Agy7p8g==",
"license": "MIT",
"peerDependencies": {
"@expo/config-plugins": "^9.0.0 || ^10.0.0",
"react": "*",
"react-native": "*",
"react-native-macos": "*"
},
"peerDependenciesMeta": {
"@expo/config-plugins": {
"optional": true
},
"react-native-macos": {
"optional": true
}
}
},
"node_modules/react-native-reanimated": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
@ -14150,7 +14239,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -14446,6 +14535,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View File

@ -27,6 +27,7 @@
"expo-image": "~2.3.2",
"expo-linking": "~7.1.7",
"expo-navigation-bar": "~4.2.7",
"expo-notifications": "~0.31.4",
"expo-router": "~5.1.3",
"expo-splash-screen": "~0.30.10",
"expo-sqlite": "~15.2.14",
@ -34,13 +35,16 @@
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.10",
"expo-web-browser": "~14.2.0",
"i18next": "^25.3.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.6.1",
"react-native": "0.79.5",
"react-native-android-widget": "^0.17.0",
"react-native-emoji-popup": "^0.3.2",
"react-native-gesture-handler": "~2.24.0",
"react-native-input-select": "^2.1.7",
"react-native-localize": "^3.5.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
@ -48,8 +52,7 @@
"react-native-vector-icons": "^10.2.0",
"react-native-web": "~0.20.0",
"react-native-webview": "13.13.5",
"sqlite": "^5.1.1",
"expo-notifications": "~0.31.4"
"sqlite": "^5.1.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",

4
translations/en.json Normal file
View File

@ -0,0 +1,4 @@
{
"welcome": "Welcome",
"hello_world": "Hello, World!"
}

View File

@ -1,4 +1,4 @@
import { Task } from "@/models/task";
import { Task } from "../models/task";
/*
#85c75c

12
utils/throttle.ts Normal file
View File

@ -0,0 +1,12 @@
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => ReturnType<T> {
let lastCall = 0;
let lastResult: ReturnType<T>;
return function (...args: Parameters<T>): ReturnType<T> {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
lastResult = fn.apply(this, args);
}
return lastResult;
};
}