564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
|
|
import { useFocusEffect, useRouter } from 'expo-router';
|
|
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 { ServerUrlModal } from '@/components/ServerUrlModal';
|
|
import SyncStatus from '@/components/SyncStatus';
|
|
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } 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 { rescheduleAllReminders } from '@/services/notifications';
|
|
import { collections } from '@/database';
|
|
import Category from '@/models/Category';
|
|
import Svg, { Path } from 'react-native-svg';
|
|
import ColorWheel from '@/components/ColorWheel';
|
|
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
|
|
|
export default function SettingsScreen() {
|
|
const router = useRouter();
|
|
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
|
|
const categories = useCategories();
|
|
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
|
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(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 [serverUrlVisible, setServerUrlVisible] = useState(false);
|
|
const [customAccentVisible, setCustomAccentVisible] = useState(false);
|
|
const [draftAccent, setDraftAccent] = useState(accentColor);
|
|
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();
|
|
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(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
refreshSyncStatus();
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
const target = getLastVisitedTab();
|
|
if (target && target !== 'settings') {
|
|
router.navigate(tabHref(target));
|
|
}
|
|
return true;
|
|
});
|
|
return () => sub.remove();
|
|
}, [router])
|
|
);
|
|
|
|
const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
|
|
const defaultCategoryLabel = defaultCategory?.name ?? 'None';
|
|
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
|
|
|
|
const tagReminderLabel = (categoryId: string) => {
|
|
const selected = tagReminderPreferences[categoryId] ?? [];
|
|
if (selected.length === 0) return `Default (${reminderLabel})`;
|
|
return selected
|
|
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
|
|
.join(', ');
|
|
};
|
|
|
|
const openCustomAccent = () => {
|
|
setDraftAccent(accentColor);
|
|
setCustomAccentVisible(true);
|
|
};
|
|
|
|
const handleDefaultCategory = (value: string | string[]) => {
|
|
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
|
|
};
|
|
|
|
const handleReminderPreference = (value: string | string[]) => {
|
|
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
|
|
};
|
|
|
|
const handleTagReminderPreference = async (categoryId: string, value: string | string[]) => {
|
|
const selected = (Array.isArray(value) ? value : [value]).filter(
|
|
(v): v is typeof reminderPreference => v !== 'none'
|
|
);
|
|
setTagReminderPreferences({ ...tagReminderPreferences, [categoryId]: selected });
|
|
if (!notifications) return;
|
|
try {
|
|
const tasks = await collections.tasks.query().fetch();
|
|
await rescheduleAllReminders(tasks, notifications, reminderPreference);
|
|
} catch {
|
|
// ignore reschedule failures
|
|
}
|
|
};
|
|
|
|
const handleTodoAheadDays = (value: string | string[]) => {
|
|
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
|
|
};
|
|
|
|
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 }]}>Tags</Text>
|
|
{categories.map((category) => (
|
|
<ListItem
|
|
key={category.id}
|
|
title={category.name}
|
|
subtitle={category.id === defaultCategoryId ? 'Default tag' : 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.accentText} strokeWidth={2} strokeLinecap="round" />
|
|
</Svg>
|
|
</View>
|
|
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</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
|
|
/>
|
|
{categories.map((category) => (
|
|
<ListItem
|
|
key={`tag-reminder-${category.id}`}
|
|
title={category.name}
|
|
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
|
|
leftElement={
|
|
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
|
}
|
|
onPress={() => notifications && setReminderTagPicker(category)}
|
|
showChevron
|
|
/>
|
|
))}
|
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
|
<ListItem
|
|
title="Default Tag"
|
|
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
|
|
/>
|
|
<ListItem
|
|
title="Show Completed Tasks"
|
|
subtitle="Hide or show finished tasks in the Todo list"
|
|
rightElement={
|
|
<Switch
|
|
value={showCompleted}
|
|
onValueChange={setShowCompleted}
|
|
thumbColor="#FFFFFF"
|
|
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
|
/>
|
|
}
|
|
/>
|
|
<ListItem
|
|
title="Delete by Swiping"
|
|
subtitle="Swipe right to delete task"
|
|
rightElement={
|
|
<Switch
|
|
value={deleteBySwipe}
|
|
onValueChange={setDeleteBySwipe}
|
|
thumbColor="#FFFFFF"
|
|
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
|
/>
|
|
}
|
|
showChevron
|
|
/>
|
|
<ListItem
|
|
title="Show Calendar Tasks"
|
|
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
|
onPress={() => setPicker('todoAhead')}
|
|
showChevron
|
|
/>
|
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
|
|
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
|
|
<View style={styles.accentPresets}>
|
|
{ACCENT_PRESETS.map((c) => (
|
|
<TouchableOpacity
|
|
key={c}
|
|
style={[
|
|
styles.accentSwatch,
|
|
{ backgroundColor: c },
|
|
accentColor.toUpperCase() === c && styles.accentSwatchSelected,
|
|
]}
|
|
onPress={() => setAccentColor(c)}
|
|
activeOpacity={0.8}
|
|
>
|
|
{accentColor.toUpperCase() === c && (
|
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
|
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
</Svg>
|
|
)}
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
<ListItem
|
|
title="Custom Color"
|
|
subtitle={accentColor}
|
|
leftElement={<View style={[styles.categoryDot, { backgroundColor: accentColor }]} />}
|
|
onPress={openCustomAccent}
|
|
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 }]}>Server</Text>
|
|
<ListItem
|
|
title="Backend URL"
|
|
subtitle={apiUrl}
|
|
onPress={() => setServerUrlVisible(true)}
|
|
showChevron
|
|
/>
|
|
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
|
|
<ListItem
|
|
title="Check for Updates"
|
|
subtitle={updateSubtitle}
|
|
onPress={handleCheckUpdates}
|
|
showChevron
|
|
/>
|
|
<ListItem
|
|
title="Version"
|
|
subtitle={getCurrentAppVersion()}
|
|
/>
|
|
<ListItem
|
|
title="Privacy Policy"
|
|
onPress={() => setLegalVisible('privacy')}
|
|
showChevron
|
|
/>
|
|
<ListItem
|
|
title="Terms of Service"
|
|
onPress={() => setLegalVisible('terms')}
|
|
showChevron
|
|
/>
|
|
</ScrollView>
|
|
|
|
<OptionPickerModal
|
|
visible={picker === 'category'}
|
|
title="Default Tag"
|
|
options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
|
selectedValue={defaultCategoryId}
|
|
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)}
|
|
/>
|
|
|
|
{categories.map((category) => (
|
|
<OptionPickerModal
|
|
key={`tag-reminder-picker-${category.id}`}
|
|
visible={reminderTagPicker?.id === category.id}
|
|
title={`Reminders · ${category.name}`}
|
|
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
|
|
selectedValue={tagReminderPreferences[category.id] ?? []}
|
|
multiSelect
|
|
onSelect={(value) => handleTagReminderPreference(category.id, value)}
|
|
onClose={() => setReminderTagPicker(null)}
|
|
/>
|
|
))}
|
|
|
|
<OptionPickerModal
|
|
visible={picker === 'todoAhead'}
|
|
title="Calendar Tasks in Todo"
|
|
options={AHEAD_OPTIONS}
|
|
selectedValue={String(todoAheadDays)}
|
|
onSelect={handleTodoAheadDays}
|
|
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)} />
|
|
|
|
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
|
|
|
|
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
|
|
<Pressable
|
|
style={styles.accentModalOverlay}
|
|
onPress={() => setCustomAccentVisible(false)}
|
|
>
|
|
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
|
|
<View style={styles.accentModalHeader}>
|
|
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
|
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
<Svg width={18} height={18} viewBox="0 0 24 24">
|
|
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
|
|
</Svg>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
|
|
<View style={styles.accentModalActions}>
|
|
<TouchableOpacity
|
|
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
|
|
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
|
|
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
|
|
<LegalModal
|
|
visible={legalVisible !== null}
|
|
type={legalVisible}
|
|
onClose={() => setLegalVisible(null)}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 29 }, (_, i) => ({
|
|
value: String(i),
|
|
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
|
|
}));
|
|
|
|
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();
|
|
}
|
|
|
|
function contrastOnSwatch(hex: string): string {
|
|
const h = hex.replace(/^#/, '');
|
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
return luminance > 0.5 ? '#111111' : '#FFFFFF';
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
flexGrow: 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',
|
|
},
|
|
sectionHint: {
|
|
fontSize: 12,
|
|
marginLeft: 4,
|
|
marginBottom: 8,
|
|
},
|
|
accentPresets: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 10,
|
|
paddingHorizontal: 4,
|
|
marginBottom: 4,
|
|
},
|
|
accentSwatch: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
accentSwatchSelected: {
|
|
borderWidth: 2,
|
|
borderColor: '#FFFFFF',
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 3,
|
|
elevation: 3,
|
|
},
|
|
accentModalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 24,
|
|
},
|
|
accentModalSheet: {
|
|
width: '100%',
|
|
maxWidth: 400,
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
},
|
|
accentModalHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 16,
|
|
},
|
|
accentModalTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
},
|
|
accentModalActions: {
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
marginTop: 16,
|
|
},
|
|
accentModalButton: {
|
|
flex: 1,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
alignItems: 'center',
|
|
},
|
|
accentModalButtonPrimary: {
|
|
borderWidth: 0,
|
|
},
|
|
accentModalButtonText: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
});
|