basic working app currently wworking on the sync to work
Build APK / build (push) Canceled after 0s

This commit is contained in:
2026-08-06 11:16:47 +02:00
commit 793e2d9044
192 changed files with 42523 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
export default function TabLayout() {
const { theme } = useSettings();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#8E8E8E',
tabBarStyle: {
backgroundColor: theme.tabBarBg,
borderTopWidth: 1,
borderTopColor: theme.border,
height: 64,
paddingBottom: 0,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Tasks',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="checklist" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen
name="calendar"
options={{
title: 'Calendar',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="calendar" focused={focused} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="gear" focused={focused} color={color} />
),
}}
/>
</Tabs>
);
}
+231
View File
@@ -0,0 +1,231 @@
import React, { useMemo, useRef, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native';
import { useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks';
import { toggleTaskComplete } from '@/utils/taskActions';
import { useSettings } from '@/theme';
import { TaskItem } from '@/components/TaskItem';
import { QuickAddBar } from '@/components/QuickAddBar';
import { TaskData } from '@/types';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday } from 'date-fns';
import Svg, { Path } from 'react-native-svg';
const DAY_WIDTH = 44;
const DAY_GAP = 6;
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date());
const stripRef = useRef<ScrollView>(null);
const days = useMemo(
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }),
[visibleMonth]
);
const { tasks, loading } = useTasksByDate(selectedDate);
const handleDayPress = (day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonth)) {
setVisibleMonth(day);
}
};
const handlePrevMonth = () => {
const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev));
}
};
const handleNextMonth = () => {
const next = addMonths(visibleMonth, 1);
setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next));
}
};
const handleToggleComplete = async (taskId: string) => {
await toggleTaskComplete(taskId);
};
const today = new Date();
const scrollToDay = (day: Date) => {
const index = days.findIndex((d) => isSameDay(d, day));
if (index >= 0) {
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
}
};
React.useEffect(() => {
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleMonth]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} />
<ScrollView
ref={stripRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.dateStrip}
>
{days.map((day) => {
const isSelected = isSameDay(day, selectedDate);
const isCurrent = isToday(day);
return (
<TouchableOpacity
key={day.toISOString()}
style={[
styles.dayButton,
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 },
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent },
]}
onPress={() => handleDayPress(day)}
activeOpacity={0.7}
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
<View style={styles.monthRow}>
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text>
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TaskItem
task={item as TaskData}
onToggle={() => handleToggleComplete(item.id)}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={
loading ? (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
</View>
) : (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text>
</View>
)
}
contentContainerStyle={styles.listContent}
/>
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
dateStrip: {
paddingHorizontal: 16,
paddingTop: 12,
gap: DAY_GAP,
},
dayButton: {
width: DAY_WIDTH,
height: 60,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
dayTextSelected: {
color: '#FFFFFF',
},
dayWeekday: {
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
dayNumber: {
fontSize: 16,
fontWeight: '600',
},
monthRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
paddingVertical: 12,
},
monthNav: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthLabel: {
fontSize: 15,
fontWeight: '700',
letterSpacing: 1,
minWidth: 120,
textAlign: 'center',
},
listContent: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 100,
flexGrow: 1,
},
separator: {
height: 8,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 64,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
emptySubtext: {
fontSize: 13,
},
});
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter';
import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
export default function TasksScreen() {
const { isReady } = useDatabase();
const { theme } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
const [selectionActive, setSelectionActive] = React.useState(false);
if (!isReady) {
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>Loading...</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} />
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
<QuickAddBar />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
+256
View File
@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native';
import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import { CategoryEditorModal } from '@/components/CategoryEditorModal';
import { SyncModal } from '@/components/SyncModal';
import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal';
import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth';
import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
export default function SettingsScreen() {
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference } = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const refreshSyncStatus = async () => {
const token = await getAuthToken();
if (!token) {
setSyncSubtitle('Not signed in');
return;
}
const user = await getAuthUser();
const lastSync = await getLastSyncTime();
if (user) {
setSyncSubtitle(
lastSync ? `Signed in · synced ${formatSyncTime(lastSync)}` : `Signed in as ${user.username}`
);
} else {
setSyncSubtitle('Not signed in');
}
};
React.useEffect(() => {
refreshSyncStatus();
}, []);
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None');
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const handleDefaultCategory = (value: string) => {
setDefaultCategoryId(value);
};
const handleReminderPreference = (value: string) => {
setReminderPreference(value as typeof reminderPreference);
};
const editorVisible = editingCategory !== null;
const editorCategory = editingCategory === 'new' ? null : editingCategory;
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text>
{categories.map((category) => (
<ListItem
key={category.id}
title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => setEditingCategory(category)}
showChevron
/>
))}
<TouchableOpacity
style={[styles.addCategoryRow, { borderColor: theme.borderStrong }]}
onPress={() => setEditingCategory('new')}
activeOpacity={0.7}
>
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
</Svg>
</View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
</TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
<ListItem
title="Push Notifications"
subtitle="Receive task reminders"
rightElement={
<Switch
value={notifications}
onValueChange={setNotifications}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem
title="Reminder Preferences"
subtitle={notifications ? reminderLabel : 'Enable notifications first'}
onPress={() => notifications && setPicker('reminder')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem
title="Default Category"
subtitle={defaultCategoryLabel}
onPress={() => setPicker('category')}
showChevron
/>
<ListItem
title="Sort Tasks By"
subtitle={SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Date'}
onPress={() => setPicker('sort')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus />
<ListItem
title="Backup & Sync"
subtitle={syncSubtitle}
onPress={() => setSyncVisible(true)}
showChevron
/>
<ListItem
title="Friends"
subtitle="Manage friends and requests"
onPress={() => setFriendsVisible(true)}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem
title="Version"
subtitle="1.0.0"
/>
<ListItem
title="Privacy Policy"
onPress={() => setLegalVisible('privacy')}
showChevron
/>
<ListItem
title="Terms of Service"
onPress={() => setLegalVisible('terms')}
showChevron
/>
</ScrollView>
<OptionPickerModal
visible={picker === 'category'}
title="Default Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
selectedValue={defaultCategoryId || categories[0]?.id}
onSelect={handleDefaultCategory}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'sort'}
title="Sort Tasks By"
options={SORT_OPTIONS}
selectedValue={sortBy}
onSelect={(value) => setSortBy(value as typeof sortBy)}
onClose={() => setPicker(null)}
/>
<OptionPickerModal
visible={picker === 'reminder'}
title="Reminder Preferences"
options={REMINDER_OPTIONS}
selectedValue={reminderPreference}
onSelect={handleReminderPreference}
onClose={() => setPicker(null)}
/>
<CategoryEditorModal
visible={editorVisible}
category={editorCategory}
categoryCount={categories.length}
onClose={() => setEditingCategory(null)}
/>
<SyncModal visible={syncVisible} onClose={() => { setSyncVisible(false); refreshSyncStatus(); }} />
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<LegalModal
visible={legalVisible !== null}
type={legalVisible}
onClose={() => setLegalVisible(null)}
/>
</SafeAreaView>
);
}
function formatSyncTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
return new Date(timestamp).toLocaleDateString();
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 8,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 24,
marginBottom: 8,
marginLeft: 4,
},
categoryDot: {
width: 20,
height: 20,
borderRadius: 10,
},
addCategoryRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
marginBottom: 8,
},
addIcon: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
addCategoryText: {
fontSize: 15,
fontWeight: '600',
},
});