@@ -45,6 +45,15 @@ export default function TabLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stats"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="stats" focused={focused} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import React, { useMemo, useRef, useState, useCallback } 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 { useTaskModals } from '@/hooks/useTaskModals';
|
||||
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 { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday, startOfDay } from 'date-fns';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
|
||||
const DAY_WIDTH = 44;
|
||||
const DAY_GAP = 6;
|
||||
@@ -17,6 +19,7 @@ const DAY_GAP = 6;
|
||||
export default function CalendarScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { modals, openTaskMenu, openTaskDelete } = useTaskModals();
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const stripRef = useRef<ScrollView>(null);
|
||||
@@ -28,46 +31,57 @@ export default function CalendarScreen() {
|
||||
|
||||
const { tasks, loading } = useTasksByDate(selectedDate);
|
||||
|
||||
const handleDayPress = (day: Date) => {
|
||||
const handleDayPress = useCallback((day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonth)) {
|
||||
setVisibleMonth(day);
|
||||
}
|
||||
};
|
||||
}, [visibleMonth]);
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
const prev = addMonths(visibleMonth, -1);
|
||||
setVisibleMonth(prev);
|
||||
if (!isSameMonth(selectedDate, prev)) {
|
||||
setSelectedDate(startOfMonth(prev));
|
||||
}
|
||||
};
|
||||
}, [visibleMonth, selectedDate]);
|
||||
|
||||
const handleNextMonth = () => {
|
||||
const handleNextMonth = useCallback(() => {
|
||||
const next = addMonths(visibleMonth, 1);
|
||||
setVisibleMonth(next);
|
||||
if (!isSameMonth(selectedDate, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
};
|
||||
}, [visibleMonth, selectedDate]);
|
||||
|
||||
const handleToggleComplete = async (taskId: string) => {
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const scrollToDay = (day: Date) => {
|
||||
const scrollToDay = useCallback((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 });
|
||||
}
|
||||
};
|
||||
}, [days]);
|
||||
|
||||
React.useEffect(() => {
|
||||
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibleMonth]);
|
||||
const target = isSameMonth(selectedDate, visibleMonth) ? selectedDate : startOfDay(new Date());
|
||||
scrollToDay(target);
|
||||
}, [visibleMonth, scrollToDay, selectedDate]);
|
||||
|
||||
const renderTask = useCallback(
|
||||
({ item }: { item: TaskData }) => (
|
||||
<TaskItem
|
||||
task={item}
|
||||
onToggle={() => handleToggleComplete(item.id)}
|
||||
onDelete={() => openTaskDelete(item)}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })}
|
||||
onMenuOpen={() => openTaskMenu(item)}
|
||||
/>
|
||||
),
|
||||
[handleToggleComplete, openTaskDelete, openTaskMenu, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
@@ -79,30 +93,16 @@ export default function CalendarScreen() {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{days.map((day) => (
|
||||
<DayButton
|
||||
key={day.toISOString()}
|
||||
day={day}
|
||||
selected={isSameDay(day, selectedDate)}
|
||||
current={isToday(day) && !isSameDay(day, selectedDate)}
|
||||
onPress={handleDayPress}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.monthRow}>
|
||||
@@ -122,14 +122,8 @@ export default function CalendarScreen() {
|
||||
<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} />}
|
||||
renderItem={renderTask}
|
||||
ItemSeparatorComponent={MemoSeparator}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
@@ -149,10 +143,46 @@ export default function CalendarScreen() {
|
||||
dueDate={selectedDate.getTime()}
|
||||
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`}
|
||||
/>
|
||||
|
||||
{modals(() => {})}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
interface DayButtonProps {
|
||||
day: Date;
|
||||
selected: boolean;
|
||||
current: boolean;
|
||||
onPress: (day: Date) => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const DayButton = React.memo(function DayButton({ day, selected, current, onPress, theme }: DayButtonProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dayButton,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
current && { borderColor: theme.accent, borderWidth: 1.5 },
|
||||
selected && { backgroundColor: theme.accent, borderColor: theme.accent },
|
||||
]}
|
||||
onPress={() => onPress(day)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.dayWeekday, { color: theme.textMuted }, selected && styles.dayTextSelected]}>
|
||||
{format(day, 'EEE').charAt(0)}
|
||||
</Text>
|
||||
<Text style={[styles.dayNumber, { color: theme.text }, selected && styles.dayTextSelected]}>
|
||||
{format(day, 'd')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const MemoSeparator = React.memo(function Separator() {
|
||||
return <View style={styles.separator} />;
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -11,7 +11,6 @@ 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 (
|
||||
@@ -24,8 +23,10 @@ export default function TasksScreen() {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="TODO" showLogo={true} />
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} />
|
||||
<View style={styles.categoryFilterWrapper}>
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
</View>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<QuickAddBar />
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -35,6 +36,9 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryFilterWrapper: {
|
||||
height: 36,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking } from 'react-native';
|
||||
import { Header } from '@/components/Header';
|
||||
import { ListItem } from '@/components/ListItem';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
@@ -12,6 +12,7 @@ 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 { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
|
||||
import { getLastSyncTime } from '@/database/sync';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
@@ -26,6 +27,28 @@ export default function SettingsScreen() {
|
||||
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
|
||||
const [serverUrlVisible, setServerUrlVisible] = useState(false);
|
||||
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
|
||||
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
setUpdateSubtitle('Checking...');
|
||||
const update = await checkForUpdates();
|
||||
if (!update) {
|
||||
setUpdateSubtitle('Up to date');
|
||||
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
|
||||
return;
|
||||
}
|
||||
setUpdateSubtitle(`${update.version} available`);
|
||||
const url = update.apkUrl ?? update.releaseUrl;
|
||||
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
|
||||
{ text: 'Later', style: 'cancel' },
|
||||
{
|
||||
text: 'Download',
|
||||
onPress: () => {
|
||||
if (url) Linking.openURL(url).catch(() => {});
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const refreshSyncStatus = async () => {
|
||||
const token = await getAuthToken();
|
||||
@@ -45,6 +68,7 @@ export default function SettingsScreen() {
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
refreshSyncStatus();
|
||||
}, []);
|
||||
|
||||
@@ -52,12 +76,12 @@ export default function SettingsScreen() {
|
||||
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 handleDefaultCategory = (value: string | string[]) => {
|
||||
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
||||
};
|
||||
|
||||
const handleReminderPreference = (value: string) => {
|
||||
setReminderPreference(value as typeof reminderPreference);
|
||||
const handleReminderPreference = (value: string | string[]) => {
|
||||
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
||||
};
|
||||
|
||||
const editorVisible = editingCategory !== null;
|
||||
@@ -146,9 +170,15 @@ export default function SettingsScreen() {
|
||||
showChevron
|
||||
/>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
||||
<ListItem
|
||||
title="Check for Updates"
|
||||
subtitle={updateSubtitle}
|
||||
onPress={handleCheckUpdates}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Version"
|
||||
subtitle="1.0.0"
|
||||
subtitle={getCurrentAppVersion()}
|
||||
/>
|
||||
<ListItem
|
||||
title="Privacy Policy"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useStats } from '@/hooks/useStats';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
export default function StatsScreen() {
|
||||
const { theme } = useSettings();
|
||||
const stats = useStats();
|
||||
|
||||
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="Stats" showLogo={true} />
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
|
||||
<View style={styles.summaryGrid}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
|
||||
</View>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
|
||||
{stats.overdueCount}
|
||||
</Text>
|
||||
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Last 7 days</Text>
|
||||
<View style={styles.chartRow}>
|
||||
{stats.daily.map((d, i) => {
|
||||
const height = (d.count / maxDaily) * BASE_HEIGHT;
|
||||
return (
|
||||
<View key={i} style={styles.chartCol}>
|
||||
<View style={styles.chartBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.chartLabel, { color: theme.textFaint }]}>{d.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
|
||||
{stats.byCategory.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
) : (
|
||||
stats.byCategory.map((c) => (
|
||||
<View key={c.id} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: c.color }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{c.count}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
|
||||
{stats.byPriority.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
) : (
|
||||
stats.byPriority.map((p) => (
|
||||
<View key={p.priority} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_HEIGHT = 90;
|
||||
|
||||
function priorityColor(priority: string): string {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return '#EF5350';
|
||||
case 'medium':
|
||||
return '#FFA726';
|
||||
case 'low':
|
||||
return '#66BB6A';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
gap: 12,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 14,
|
||||
},
|
||||
summaryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
summaryItem: {
|
||||
width: '33.33%',
|
||||
marginBottom: 16,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
chartRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
},
|
||||
chartCol: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chartBarTrack: {
|
||||
height: BASE_HEIGHT,
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
},
|
||||
chartBar: {
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
minHeight: 4,
|
||||
},
|
||||
chartLabel: {
|
||||
fontSize: 11,
|
||||
marginTop: 6,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginRight: 10,
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
},
|
||||
rowValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,31 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { DatabaseProvider } from '@/hooks/useDatabase';
|
||||
import { SettingsProvider } from '@/theme';
|
||||
import { FriendsProvider } from '@/hooks/useFriends';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { UpdateNotifier } from '@/components/UpdateNotifier';
|
||||
import { Linking } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
||||
|
||||
function RootNavigator() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleUrl = (url: string | null) => {
|
||||
if (!url || !url.includes('://quick-add')) return;
|
||||
router.replace('/(tabs)');
|
||||
requestQuickAddFocus();
|
||||
};
|
||||
|
||||
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
@@ -23,6 +43,7 @@ export default function RootLayout() {
|
||||
<SettingsProvider>
|
||||
<DatabaseProvider>
|
||||
<FriendsProvider>
|
||||
<UpdateNotifier />
|
||||
<RootNavigator />
|
||||
</FriendsProvider>
|
||||
</DatabaseProvider>
|
||||
|
||||
@@ -35,6 +35,7 @@ const taskSchema = z.object({
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||
reminders: z.string().optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||
});
|
||||
@@ -69,6 +70,7 @@ export default function AddTaskScreen() {
|
||||
repeatInterval: 1,
|
||||
repeatDays: [],
|
||||
reminder: 'none',
|
||||
reminders: '',
|
||||
assigneeId: null,
|
||||
subtasks: [],
|
||||
},
|
||||
@@ -88,6 +90,7 @@ export default function AddTaskScreen() {
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
const reminder = watch('reminder');
|
||||
const reminders = watch('reminders');
|
||||
const dueDate = watch('dueDate');
|
||||
const assigneeId = watch('assigneeId');
|
||||
|
||||
@@ -124,6 +127,7 @@ export default function AddTaskScreen() {
|
||||
t.repeatDays = (data.repeatDays || []).join(',');
|
||||
t.seriesId = seriesId;
|
||||
t.reminder = data.reminder || 'none';
|
||||
t.reminders = data.reminders || '';
|
||||
t.assigneeId = data.assigneeId ?? null;
|
||||
t.createdAt = now;
|
||||
t.updatedAt = now;
|
||||
@@ -213,9 +217,9 @@ export default function AddTaskScreen() {
|
||||
}}
|
||||
/>
|
||||
<ReminderSelector
|
||||
value={reminder}
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminder', value)}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
@@ -255,8 +259,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 100,
|
||||
gap: 24,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 120,
|
||||
gap: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { TaskNameInput } from '@/components/TaskNameInput';
|
||||
import { DateTimePickerComponent } from '@/components/DateTimePicker';
|
||||
import { PrioritySelector } from '@/components/PrioritySelector';
|
||||
import { RepeatSelector } from '@/components/RepeatSelector';
|
||||
import { ReminderSelector } from '@/components/ReminderSelector';
|
||||
import { DescriptionInput } from '@/components/DescriptionInput';
|
||||
import { FormButtons } from '@/components/FormButtons';
|
||||
import { AssigneeSelector } from '@/components/AssigneeSelector';
|
||||
import { useForm, FormProvider, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { collections } from '@/database';
|
||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
const subtaskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Subtask name is required').max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||
reminders: z.string().optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export default function SubtaskDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { isReady } = useDatabase();
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { friends } = useFriends();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [notFound, setNotFound] = React.useState(false);
|
||||
|
||||
const methods = useForm<SubtaskFormData>({
|
||||
resolver: zodResolver(subtaskSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
endTime: '',
|
||||
allDay: false,
|
||||
repeat: 'none',
|
||||
repeatInterval: 1,
|
||||
repeatDays: [],
|
||||
reminder: 'none',
|
||||
reminders: '',
|
||||
assigneeId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
const reminder = watch('reminder');
|
||||
const reminders = watch('reminders');
|
||||
const dueDate = watch('dueDate');
|
||||
const assigneeId = watch('assigneeId');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!id || !isReady) return;
|
||||
let mounted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const subtask = await collections.subtasks.find(id);
|
||||
if (!mounted) return;
|
||||
reset({
|
||||
title: subtask.title,
|
||||
description: subtask.description,
|
||||
priority: subtask.priority,
|
||||
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||
dueTime: subtask.dueTime,
|
||||
endTime: subtask.endTime || '',
|
||||
allDay: subtask.allDay ?? false,
|
||||
repeat: subtask.repeat,
|
||||
repeatInterval: subtask.repeatInterval || 1,
|
||||
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
reminder: (subtask.reminder || 'none') as Reminder,
|
||||
reminders: subtask.reminders || '',
|
||||
assigneeId: subtask.assigneeId ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
if (mounted) setNotFound(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, [id, isReady, reset]);
|
||||
|
||||
const onSubmit = async (data: SubtaskFormData) => {
|
||||
if (!id || !isReady) return;
|
||||
|
||||
await updateSubtask(id, {
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
priority: data.priority,
|
||||
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||
dueTime: data.dueTime || '',
|
||||
endTime: data.endTime || '',
|
||||
allDay: data.allDay ?? false,
|
||||
repeat: data.repeat,
|
||||
repeatInterval: data.repeatInterval || 1,
|
||||
repeatDays: (data.repeatDays || []).join(','),
|
||||
reminder: data.reminder || 'none',
|
||||
assigneeId: data.assigneeId ?? null,
|
||||
});
|
||||
|
||||
router.back();
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
Alert.alert('Delete Subtask', 'This action cannot be undone.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (!id) return;
|
||||
await deleteSubtask(id);
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (!isReady || !loaded) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
|
||||
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header
|
||||
title="Edit Subtask"
|
||||
showLogo={true}
|
||||
rightAction={
|
||||
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
<FormProvider {...methods}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<TaskNameInput
|
||||
value={field.value}
|
||||
onChangeText={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
error={errors.title?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
/>
|
||||
<DateTimePickerComponent control={control as any} />
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
interval={repeatInterval}
|
||||
days={repeatDays}
|
||||
onChange={(nextRepeat, nextInterval, nextDays) => {
|
||||
setValue('repeat', nextRepeat);
|
||||
setValue('repeatInterval', nextInterval);
|
||||
setValue('repeatDays', nextDays);
|
||||
}}
|
||||
/>
|
||||
<ReminderSelector
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<DescriptionInput
|
||||
value={field.value ?? ''}
|
||||
onChangeText={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
||||
</FormProvider>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
keyboardAvoiding: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 100,
|
||||
gap: 24,
|
||||
},
|
||||
deleteButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -22,9 +22,9 @@ import { Q } from '@nozbe/watermelondb';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||
@@ -39,10 +39,38 @@ const taskSchema = z.object({
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
|
||||
reminders: z.string().optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
|
||||
});
|
||||
|
||||
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
|
||||
const { theme } = useSettings();
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
|
||||
<View style={styles.sectionHeaderLeft}>
|
||||
{icon}
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
|
||||
</View>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
{expanded && <View style={styles.sectionContent}>{children}</View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { isReady } = useDatabase();
|
||||
@@ -80,6 +108,7 @@ export default function TaskDetailScreen() {
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
const reminder = watch('reminder');
|
||||
const reminders = watch('reminders');
|
||||
const dueDate = watch('dueDate');
|
||||
const assigneeId = watch('assigneeId');
|
||||
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
|
||||
@@ -253,38 +282,89 @@ export default function TaskDetailScreen() {
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
/>
|
||||
<DateTimePickerComponent control={control} />
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
interval={repeatInterval}
|
||||
days={repeatDays}
|
||||
onChange={(nextRepeat, nextInterval, nextDays) => {
|
||||
setValue('repeat', nextRepeat);
|
||||
setValue('repeatInterval', nextInterval);
|
||||
setValue('repeatDays', nextDays);
|
||||
}}
|
||||
/>
|
||||
<ReminderSelector
|
||||
value={reminder}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminder', value)}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<DescriptionInput
|
||||
value={field.value ?? ''}
|
||||
onChangeText={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
|
||||
<CollapsibleSection title="Date & Time" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!dueDate}>
|
||||
<DateTimePickerComponent control={control} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Repeat" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={repeat !== 'none'}>
|
||||
<RepeatSelector
|
||||
value={repeat}
|
||||
interval={repeatInterval}
|
||||
days={repeatDays}
|
||||
onChange={(nextRepeat, nextInterval, nextDays) => {
|
||||
setValue('repeat', nextRepeat);
|
||||
setValue('repeatInterval', nextInterval);
|
||||
setValue('repeatDays', nextDays);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Reminder" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
} defaultExpanded={!!reminders && reminders !== ''}>
|
||||
<ReminderSelector
|
||||
value={reminders}
|
||||
hasDueDate={!!dueDate}
|
||||
onChange={(value) => setValue('reminders', value)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Assignee" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!assigneeId}>
|
||||
<AssigneeSelector
|
||||
value={assigneeId}
|
||||
onChange={(value: string | null) => setValue('assigneeId', value)}
|
||||
friends={friends.map((f) => f.username)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Description" icon={
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!methods.getValues('description')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<DescriptionInput
|
||||
value={field.value ?? ''}
|
||||
onChangeText={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
|
||||
@@ -315,15 +395,40 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 100,
|
||||
gap: 24,
|
||||
paddingBottom: 80,
|
||||
gap: 12,
|
||||
},
|
||||
section: {
|
||||
gap: 6,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
sectionHeaderLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionContent: {
|
||||
paddingHorizontal: 2,
|
||||
gap: 6,
|
||||
},
|
||||
deleteButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
Reference in New Issue
Block a user