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' applicationId 'com.xaviscript.taskeep'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 4
versionName "1.0.0" versionName "1.1.0"
} }
signingConfigs { signingConfigs {
release { 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 android.packagingOptions.pickFirsts=**/libc++_shared.so
expo.sqlite.enableFTS=false expo.sqlite.enableFTS=false
expo.sqlite.useSQLCipher=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_STORE_FILE=taskeep.keystore
MYAPP_UPLOAD_KEY_ALIAS=taskeep MYAPP_UPLOAD_KEY_ALIAS=taskeep
MYAPP_UPLOAD_STORE_PASSWORD=123?_Tk_?123 MYAPP_UPLOAD_STORE_PASSWORD=123?_Tk_?123
MYAPP_UPLOAD_KEY_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", "name": "Taskeep",
"main": "index.tsx", "main": "index.tsx",
"slug": "taskeep", "slug": "taskeep",
"version": "1.0.0", "version": "1.1.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "taskeep", "scheme": "taskeep",
@ -18,7 +18,8 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"package": "com.xaviscript.taskeep" "package": "com.xaviscript.taskeep",
"versionCode": 4
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@ -33,7 +34,11 @@
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splash-icon.png",
"imageWidth": 200, "imageWidth": 200,
"resizeMode": "contain", "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 { useFocusEffect } from 'expo-router';
import { navigate } from 'expo-router/build/global-state/routing'; import { navigate } from 'expo-router/build/global-state/routing';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PermissionsAndroid, Platform } from 'react-native'; import { PermissionsAndroid, Platform } from 'react-native';
import { TaskItem } from '../../components/taskItem'; import { TaskItem } from '../../components/taskItem';
import { ThemedText } from '../../components/ThemedText'; import { ThemedText } from '../../components/ThemedText';
@ -33,6 +34,7 @@ export default function HomeScreen() {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const { t } = useTranslation();
useEffect(() => { // Initial data fetch useEffect(() => { // Initial data fetch
(async () => { (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 () => { const RefreshTasks = async () => {
try { try {
const fetchedTasks = await taskRepository.findAll(); const fetchedTasks = await taskRepository.findAll();
fetchedTasks.sort((a, b) => { fetchedTasks.sort((a, b) => {
return (a.daysToRedo! * 24 * 3600 * 1000 - (Date.now() - a.lastDone!)) - ((b.daysToRedo! * 24 * 3600 * 1000) - (Date.now() - b.lastDone!)); return (a.daysToRedo! * 24 * 3600 * 1000 - (Date.now() - a.lastDone!)) - ((b.daysToRedo! * 24 * 3600 * 1000) - (Date.now() - b.lastDone!));
}); })
console.log("Tasks fetched:", fetchedTasks); console.log("Tasks fetched:", fetchedTasks);

View File

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

View File

@ -14,12 +14,42 @@ import { SQLiteDataService } from '../../services/data/sqliteDataService';
const taskRepository = new TaskRepository(new SQLiteDataService<Task>(TaskQuery)); const taskRepository = new TaskRepository(new SQLiteDataService<Task>(TaskQuery));
const categoryRepository = new CategoryRepository(new SQLiteDataService<Category>(CategoryQuery)); 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() { export default function TasksForm() {
const { task: taskJson } = useLocalSearchParams(); const { task: taskJson } = useLocalSearchParams();
const task: Task = taskJson ? JSON.parse(taskJson as string) : null; const task: Task = taskJson ? JSON.parse(taskJson as string) : null;
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [selectedTimeUnit, setSelectedTimeUnit] = useState<string>("days");
const router = useRouter(); const router = useRouter();
@ -29,13 +59,35 @@ export default function TasksForm() {
category: task?.category || -1, 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(() => { useFocusEffect(React.useCallback(() => {
categoryRepository.findAll().then((categories) => { categoryRepository.findAll().then((categories) => {
categories.push(new Category("_create new_", "", 0)); categories.push(new Category("Create new category", "", 0));
setCategories(categories); setCategories(categories);
if (newTask.category === 0) { if (newTask.category === 0) {
setNewTask({ ...newTask, category: -1 }); setNewTask({ ...newTask, category: -1 });
} }
adaptDaysToGoToUnit();
}).catch((error) => { }).catch((error) => {
console.error("Error fetching categories:", error); console.error("Error fetching categories:", error);
}); });
@ -65,14 +117,47 @@ export default function TasksForm() {
value={newTask.title} value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.nativeEvent.text })} onChange={(e) => setNewTask({ ...newTask, title: e.nativeEvent.text })}
/> />
<View style={{ flexDirection: 'row', gap: 8, height: 60 }}>
<TextInput <TextInput
placeholder="Days after which task is due *" placeholder={`${timeUnits.find(unit => unit.value === selectedTimeUnit)?.label} after which task is due *`}
style={styles.inputText} style={styles.inputText}
placeholderTextColor="#333" placeholderTextColor="#333"
keyboardType='numeric' keyboardType='numeric'
value={isNaN(newTask.daysToRedo ?? NaN) ? undefined : newTask.daysToRedo?.toString()} value={isNaN(newTask.daysToRedo ?? NaN) ? undefined : newTask.daysToRedo?.toString()}
onChange={(e) => setNewTask({ ...newTask, daysToRedo: e.nativeEvent.text.length > 0 ? parseInt(e.nativeEvent.text) : NaN })} 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 <Dropdown
placeholder="Select category *" placeholder="Select category *"
options={ options={
@ -103,9 +188,15 @@ export default function TasksForm() {
padding: 0, padding: 0,
marginTop: -30 marginTop: -30
}} }}
dropdownStyle={{
borderColor: '#ccc',
borderWidth: 2
}}
/> />
</ThemedView> </ThemedView>
<Pressable style={styles.addButton} onPress={() => { <Pressable style={styles.addButton} onPress={() => {
newTask.daysToRedo = timeUnitToDays(newTask.daysToRedo, selectedTimeUnit);
if (newTask.title.length > 0 && newTask.daysToRedo > 0 && newTask.category > -1) { if (newTask.title.length > 0 && newTask.daysToRedo > 0 && newTask.category > -1) {
if (task && task.id) { if (task && task.id) {
// Update existing task // Update existing task
@ -167,8 +258,9 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
}, },
inputText: { inputText: {
flex: 1,
backgroundColor: '#fff', backgroundColor: '#fff',
height: 50, height: 60,
color: '#333333', color: '#333333',
borderRadius: 8, borderRadius: 8,
padding: 10, padding: 10,

View File

@ -5,16 +5,33 @@ import { Category } from '../../models/category';
import { Task } from '../../models/task'; import { Task } from '../../models/task';
import { GetTaskColor } from '../../utils/colors'; 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[] }) { export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?: Category[] }) {
const demoTasks: Task[] = [ const demoTasks: Task[] = [
new Task('Task 1', 3, 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', 5, 1, new Date().getTime()), new Task('Task 2', 2, 1, new Date().getTime() - 1000 * 60 * 60 * 24), // 1 day ago
new Task('Task 2', 5, 1, new Date().getTime()), new Task('Task 3', 3, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 2), // 2 days ago
new Task('Task 2', 5, 1, new Date().getTime()), new Task('Task 4', 4, 1, new Date().getTime() - 1000 * 60 * 60 * 24 * 3), // 3 days ago
new Task('Task 2', 5, 1, new Date().getTime()) 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 ( return (
<FlexWidget <FlexWidget
style={{ style={{
@ -50,7 +67,13 @@ export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?:
height: 'match_parent', height: 'match_parent',
}} }}
> >
{(tasks ?? demoTasks).map((task, index) => ( {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 <FlexWidget
key={Date.now() + "-" + Math.random() + "-" + index + "-" + task.id} key={Date.now() + "-" + Math.random() + "-" + index + "-" + task.id}
style={{ style={{
@ -87,7 +110,14 @@ export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?:
}} }}
/> />
<TextWidget <TextWidget
text={categories?.find(c => c.id === task.category)?.title || 'Uncategorized'} text={`${category ? (category.icon + " " + category.title) : 'Uncategorized'}`}
style={{
color: '#ffffff',
fontSize: 12,
}}
/>
<TextWidget
text={parseInt(timeLeft.toString()) > 0 ? timeLeft + " left" : "Overdue"}
style={{ style={{
color: '#ffffff', color: '#ffffff',
fontSize: 16, fontSize: 16,
@ -95,7 +125,7 @@ export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?:
/> />
</FlexWidget> </FlexWidget>
<SvgWidget svg={getClockProgressSVG( <SvgWidget svg={getClockProgressSVG(
((task.daysToRedo! - (((Date.now() - task.lastDone!) / (24 * 3600 * 1000)))) / task.daysToRedo!) * 360, // Example calculation for degree circleDegree,
100, 100,
100, 100,
GetTaskColor(task) as ColorProp GetTaskColor(task) as ColorProp
@ -104,7 +134,8 @@ export function HelloWidget({ tasks, categories }: { tasks: Task[], categories?:
}} /> }} />
</FlexWidget> </FlexWidget>
</FlexWidget> </FlexWidget>
))} )
})}
{(tasks && tasks.length === 0) && {(tasks && tasks.length === 0) &&
<TextWidget text='No tasks created.' style={{ <TextWidget text='No tasks created.' style={{
color: '#ffffff' color: '#ffffff'

View File

@ -6,6 +6,7 @@ import { Task, TaskQuery } from '../../models/task';
import { CategoryRepository } from '../../repositories/CategoryRepository'; import { CategoryRepository } from '../../repositories/CategoryRepository';
import { TaskRepository } from '../../repositories/TaskRepository'; import { TaskRepository } from '../../repositories/TaskRepository';
import { SQLiteDataService } from '../../services/data/sqliteDataService'; import { SQLiteDataService } from '../../services/data/sqliteDataService';
import { throttle } from '../../utils/throttle';
import { HelloWidget } from './hello'; import { HelloWidget } from './hello';
@ -50,9 +51,9 @@ notify_listener = Notifications.addNotificationReceivedListener(notification =>
notify_responseListener = Notifications.addNotificationResponseReceivedListener(response => { }); notify_responseListener = Notifications.addNotificationResponseReceivedListener(response => { });
const CheckTaskAndNotify = async (): Promise<{ tasks: Task[], categories: Category[] }> => { const CheckTaskAndNotify = async (): Promise<{ newTasks: Task[], newCategories: Category[] }> => {
tasks = await taskRepository.findAll(); const tasks = await taskRepository.findAll();
categories = await categoryRepository.findAll(); const categories = await categoryRepository.findAll();
const expiredTasks = tasks.filter(task => { const expiredTasks = tasks.filter(task => {
const expirationDate = task.lastDone! + (task.daysToRedo! * 24 * 60 * 60 * 1000); // Convert days to milliseconds 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 () => { 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 }, 1000 * 60 * 30); // Check every 30 minutes
export async function widgetTaskHandler(props: WidgetTaskHandlerProps) { export async function widgetTaskHandler(props: WidgetTaskHandlerProps) {
async function updateAndRenderWidget() { 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} />); 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 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) { export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ...otherProps }: TaskItemProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
const router = useRouter(); const router = useRouter();
@ -79,7 +89,7 @@ export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ..
{task?.title} {task?.title}
</Text> </Text>
<Text style={styles(colorScheme).taskItemText}> <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> </Text>
</ThemedView> </ThemedView>
<Pressable style={styles(colorScheme).checkButton} onPress={() => { <Pressable style={styles(colorScheme).checkButton} onPress={() => {
@ -92,7 +102,7 @@ export function TaskItem({ task, categories, onUpdate, lightColor, darkColor, ..
console.error("Error updating task:", error); 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> </Pressable>
</ThemedView></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); registerWidgetTaskHandler(widgetTaskHandler);
import 'expo-router/entry'; import 'expo-router/entry';
import './i18n.js';

100
package-lock.json generated
View File

@ -32,13 +32,16 @@
"expo-symbols": "~0.4.5", "expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.10", "expo-system-ui": "~5.0.10",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~14.2.0",
"i18next": "^25.3.2",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-i18next": "^15.6.1",
"react-native": "0.79.5", "react-native": "0.79.5",
"react-native-android-widget": "^0.17.0", "react-native-android-widget": "^0.17.0",
"react-native-emoji-popup": "^0.3.2", "react-native-emoji-popup": "^0.3.2",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-input-select": "^2.1.7", "react-native-input-select": "^2.1.7",
"react-native-localize": "^3.5.1",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
@ -8491,6 +8494,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "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": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "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==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "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": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -11870,6 +11913,32 @@
"react": ">=17.0.0" "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": { "node_modules/react-is": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
@ -12009,6 +12078,26 @@
"react-native": "*" "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": { "node_modules/react-native-reanimated": {
"version": "3.17.5", "version": "3.17.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
@ -14150,7 +14239,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -14446,6 +14535,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "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": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View File

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