Files
carry-your-live/carry-your-live/app/(tabs)/settings.tsx
T

565 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, accentOpacity, setAccentOpacity, 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 [draftAccentOpacity, setDraftAccentOpacity] = useState(accentOpacity);
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} opacity={draftAccentOpacity} onChange={(color, opacity) => { setDraftAccent(color); setDraftAccentOpacity(opacity); }} />
<View style={styles.accentModalActions}>
<TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setDraftAccentOpacity(DEFAULT_ACCENT_OPACITY); setAccentColor(DEFAULT_ACCENT); setAccentOpacity(DEFAULT_ACCENT_OPACITY); }}
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); setAccentOpacity(draftAccentOpacity); 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',
},
});