Files
carry-your-live/carry-your-live/app/(tabs)/settings.tsx
T
tech08mag eeab4d1221 - src/theme.tsx - Added light/dark themes, themeMode, settings sync functions
- src/services/notifications.ts - Added useNotificationSettings hook, respects notifications toggle
- app/(tabs)/settings.tsx - Added theme mode picker, fixed notifications toggle, added settings sync buttons
2026-08-28 23:45:36 +02:00

739 lines
24 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,
DEFAULT_ACCENT_OPACITY,
ThemeMode,
} 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,
themeMode,
setThemeMode,
syncSettingsToServer,
loadSettingsFromServer,
} = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<
null | 'category' | 'sort' | 'reminder' | 'todoAhead' | 'themeMode'
>(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 [lastUpdateCheck, setLastUpdateCheck] = useState<string | null>(null);
const handleCheckUpdates = async () => {
setUpdateSubtitle('Checking...');
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
setLastUpdateCheck(new Date().toLocaleString());
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
return;
}
setUpdateSubtitle(`${update.version} available`);
setLastUpdateCheck(new Date().toLocaleString());
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 checkForUpdatesOnLoad = async () => {
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
setLastUpdateCheck(new Date().toLocaleString());
return;
}
setUpdateSubtitle(`${update.version} available`);
setLastUpdateCheck(new Date().toLocaleString());
};
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(() => {
refreshSyncStatus();
checkForUpdatesOnLoad();
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
const target = getLastVisitedTab();
if (target && target !== 'settings') {
router.navigate(tabHref(target));
}
return true;
});
return () => sub.remove();
}, [refreshSyncStatus, 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={async value => {
setNotifications(value);
if (!value) {
const { cancelAllReminders } = await import('@/services/notifications');
await cancelAllReminders();
} else {
const { rescheduleAllReminders } = await import('@/services/notifications');
const { collections } = await import('@/database');
const tasks = await collections.tasks.query().fetch();
await rescheduleAllReminders(tasks, true, reminderPreference);
}
}}
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.sectionHint, { color: theme.textFaint }]}>Theme</Text>
<ListItem
title="Theme Mode"
subtitle={
themeMode === 'system' ? 'System default' : themeMode === 'dark' ? 'Dark' : 'Light'
}
onPress={() => setPicker('themeMode')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus />
<ListItem
title="Backup & Sync"
subtitle={syncSubtitle}
onPress={() => setSyncVisible(true)}
showChevron
/>
<ListItem
title="Sync Settings to Server"
subtitle="Push local settings to backend"
onPress={async () => {
try {
await syncSettingsToServer();
Alert.alert('Success', 'Settings synced to server');
} catch {
Alert.alert('Error', 'Failed to sync settings');
}
}}
showChevron
/>
<ListItem
title="Load Settings from Server"
subtitle="Pull settings from backend"
onPress={async () => {
try {
await loadSettingsFromServer();
Alert.alert('Success', 'Settings loaded from server');
} catch {
Alert.alert('Error', 'Failed to load settings');
}
}}
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="Last checked" subtitle={lastUpdateCheck ?? 'Never'} />
<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)}
/>
<OptionPickerModal
visible={picker === 'themeMode'}
title="Theme Mode"
options={THEME_MODE_OPTIONS}
selectedValue={themeMode}
onSelect={value => setThemeMode(value as ThemeMode)}
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'}`,
}));
const THEME_MODE_OPTIONS: { value: ThemeMode; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System default' },
];
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',
},
});