working backend connectivity

This commit is contained in:
2026-08-06 11:43:38 +02:00
parent ec165beeff
commit 3d594670b0
17 changed files with 320 additions and 61 deletions
+1
View File
@@ -10,6 +10,7 @@
"supportsTablet": true
},
"android": {
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
+4 -2
View File
@@ -1,10 +1,12 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme';
export default function TabLayout() {
const { theme } = useSettings();
const insets = useSafeAreaInsets();
return (
<Tabs
@@ -15,8 +17,8 @@ export default function TabLayout() {
backgroundColor: theme.tabBarBg,
borderTopWidth: 1,
borderTopColor: theme.border,
height: 64,
paddingBottom: 0,
height: 64 + insets.bottom,
paddingBottom: insets.bottom,
},
tabBarLabelStyle: {
fontSize: 11,
+13 -2
View File
@@ -7,6 +7,7 @@ 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 } from '@/theme';
import { useCategories } from '@/hooks/useDatabase';
@@ -16,13 +17,14 @@ import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg';
export default function SettingsScreen() {
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference } = useSettings();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl } = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const refreshSyncStatus = async () => {
@@ -136,6 +138,13 @@ export default function SettingsScreen() {
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="Version"
@@ -191,6 +200,8 @@ export default function SettingsScreen() {
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<LegalModal
visible={legalVisible !== null}
type={legalVisible}
@@ -213,7 +224,7 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
flex: 1,
flexGrow: 1,
paddingHorizontal: 16,
paddingTop: 8,
},
+1 -1
View File
@@ -172,7 +172,7 @@ export default function AddTaskScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
+1 -1
View File
@@ -224,7 +224,7 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import Category from '@/models/Category';
import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants';
@@ -56,8 +56,12 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Category' : 'New Category'}
@@ -129,8 +133,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
}
@@ -150,10 +150,11 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated
const styles = StyleSheet.create({
scrollView: {
paddingVertical: 8,
paddingVertical: 0,
},
container: {
paddingHorizontal: 16,
paddingBottom: 4,
gap: 8,
alignItems: 'center',
},
@@ -7,30 +7,39 @@ interface ColorPickerInputProps {
onChange: (color: string) => void;
}
function normalizeHex(input: string): string {
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
return cleaned ? `#${cleaned}` : '';
const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
function isValidHex(color: string): boolean {
return HEX_PATTERN.test(color);
}
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
const hex = value.replace(/^#/, '').toUpperCase();
const valid = isValidHex(hex);
const handleChange = (raw: string) => {
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
onChange(`#${cleaned}`);
};
return (
<View style={styles.row}>
<View style={[styles.preview, { backgroundColor: value }]} />
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
value={value}
onChangeText={(text) => onChange(normalizeHex(text))}
placeholder="#E53935"
placeholderTextColor={theme.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={7}
/>
<View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
<TextInput
style={[styles.input, { color: theme.text }]}
value={hex}
onChangeText={handleChange}
placeholder="E53935"
placeholderTextColor={theme.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={6}
/>
</View>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
</View>
);
@@ -47,16 +56,26 @@ const styles = StyleSheet.create({
height: 36,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E0E0E0',
},
input: {
inputWrap: {
flex: 1,
height: 44,
paddingHorizontal: 14,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hash: {
fontSize: 15,
fontWeight: '600',
},
input: {
flex: 1,
fontSize: 15,
fontWeight: '500',
paddingVertical: 0,
},
hint: {
fontSize: 12,
@@ -7,15 +7,23 @@ interface ColorPickerInputProps {
onChange: (color: string) => void;
}
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings();
const valid = HEX_PATTERN.test(value);
const normalized = valid ? value.toUpperCase() : '#000000';
return (
<View style={styles.row}>
{React.createElement('input', {
type: 'color',
value: value.toUpperCase(),
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value),
value: normalized,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (HEX_PATTERN.test(e.target.value)) {
onChange(e.target.value.toUpperCase());
}
},
style: {
width: 44,
height: 44,
@@ -26,7 +34,7 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
cursor: 'pointer',
},
})}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text>
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
</View>
);
}
@@ -9,6 +9,8 @@ import {
FlatList,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends';
@@ -90,7 +92,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<KeyboardAvoidingView
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text>
+13 -15
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSettings } from '@/theme';
interface HeaderProps {
@@ -13,7 +12,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings();
return (
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}>
<View style={[styles.header, { backgroundColor: theme.background }]}>
<View style={styles.headerContent}>
{showLogo && (
<View style={styles.logoContainer}>
@@ -23,8 +22,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<View style={styles.spacer}>{rightAction}</View>
</View>
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} />
</SafeAreaView>
</View>
);
}
@@ -43,13 +41,13 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
height: 80,
paddingTop: 2,
paddingBottom: 6,
height: 44,
},
logoContainer: {
width: 36,
height: 36,
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center',
@@ -57,7 +55,7 @@ const styles = StyleSheet.create({
},
logoText: {
color: '#FFFFFF',
fontSize: 20,
fontSize: 18,
fontWeight: '700',
},
title: {
@@ -68,13 +66,13 @@ const styles = StyleSheet.create({
marginLeft: -30,
},
spacer: {
width: 36,
width: 32,
alignItems: 'flex-end',
},
bottomRounded: {
height: 24,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
marginTop: -24,
height: 16,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
marginTop: -16,
},
});
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
@@ -13,6 +14,7 @@ interface QuickAddBarProps {
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings();
const insets = useSafeAreaInsets();
const categories = useCategories();
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || '');
@@ -56,8 +58,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.wrapper}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.wrapper, { bottom: insets.bottom + 16 }]}
>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity
@@ -0,0 +1,163 @@
import React, { useEffect, useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native';
import { useSettings } from '@/theme';
import { DEFAULT_API_BASE_URL } from '@/services/auth';
import Svg, { Path } from 'react-native-svg';
interface ServerUrlModalProps {
visible: boolean;
onClose: () => void;
}
export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
const { theme, apiUrl, setApiUrl } = useSettings();
const [value, setValue] = useState(apiUrl);
useEffect(() => {
if (visible) {
setValue(apiUrl);
}
}, [visible, apiUrl]);
const handleSave = () => {
const trimmed = value.trim().replace(/\/+$/, '');
if (trimmed && /^https?:\/\/.+/.test(trimmed)) {
setApiUrl(trimmed);
}
onClose();
};
const isValid = /^https?:\/\/.+/.test(value.trim());
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} 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.textMuted} strokeWidth={2} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
<Text style={[styles.label, { color: theme.textSecondary }]}>API base URL</Text>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder={DEFAULT_API_BASE_URL}
placeholderTextColor={theme.textMuted}
value={value}
onChangeText={setValue}
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={[styles.hint, { color: theme.textMuted }]}>
Include the /api suffix, e.g. https://example.com/api
</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !isValid && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!isValid}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
sheet: {
width: '100%',
maxWidth: 380,
borderRadius: 16,
padding: 20,
gap: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
title: {
fontSize: 18,
fontWeight: '700',
},
closeButton: {
padding: 4,
},
label: {
fontSize: 13,
fontWeight: '600',
marginTop: 8,
},
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: 12,
borderWidth: 1,
fontSize: 16,
},
hint: {
fontSize: 12,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
cancelButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
cancelButtonText: {
fontSize: 15,
fontWeight: '600',
},
saveButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});
+1 -1
View File
@@ -116,7 +116,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
+5 -6
View File
@@ -263,9 +263,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, []);
const handleDragStart = useCallback(async (taskId: string) => {
enterSelection(taskId);
dragStateRef.current = { taskId, positions: await measureItems() };
}, [enterSelection, measureItems]);
}, [measureItems]);
const handleDragUpdate = useCallback((absoluteY: number) => {
const state = dragStateRef.current;
@@ -284,11 +283,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
if (target) {
(async () => {
await convertTaskToSubtask(state.taskId, target);
exitSelection();
refreshAll();
})();
}
}, [findHoverTarget, exitSelection, refreshAll]);
}, [findHoverTarget, refreshAll]);
if (loading && !refreshing) {
return (
@@ -322,11 +320,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
task={item as TaskData}
onToggle={() => handleToggle(item.id)}
onDelete={() => handleDeleteOne(item.id)}
onPress={() => selectionMode ? toggleSelect(item.id) : handleEdit(item.id)}
onPress={() => selectionMode ? toggleSelect(item.id) : toggleExpand(item.id)}
onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
onMenuOpen={() => setMenuTaskId(item.id)}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
expanded={isExpanded}
draggable
hovered={hoverTaskId === item.id}
onDragStart={() => handleDragStart(item.id)}
@@ -517,7 +516,7 @@ const styles = StyleSheet.create({
},
listContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingTop: 2,
paddingBottom: 100,
},
loadingContainer: {
+42 -2
View File
@@ -1,9 +1,48 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api';
export const DEFAULT_API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000/api';
const TOKEN_KEY = 'auth:token';
const USER_KEY = 'auth:user';
export const API_URL_KEY = 'settings:apiUrl';
function normalizeApiUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
export async function getApiBaseUrl(): Promise<string> {
try {
const stored = await AsyncStorage.getItem(API_URL_KEY);
if (!stored) return DEFAULT_API_BASE_URL;
try {
const parsed = JSON.parse(stored) as unknown;
if (typeof parsed === 'string' && parsed.trim()) {
return normalizeApiUrl(parsed);
}
} catch {
// fall through to raw value
}
if (stored.trim()) {
return normalizeApiUrl(stored);
}
} catch {
// ignore
}
return DEFAULT_API_BASE_URL;
}
export async function setApiBaseUrl(url: string): Promise<void> {
const normalized = normalizeApiUrl(url);
try {
if (normalized) {
await AsyncStorage.setItem(API_URL_KEY, normalized);
} else {
await AsyncStorage.removeItem(API_URL_KEY);
}
} catch {
// ignore
}
}
export interface AuthUser {
id: string;
@@ -47,6 +86,7 @@ export async function signOutAuth(): Promise<void> {
export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = await getAuthToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
@@ -54,7 +94,7 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
if (!response.ok) {
let message = `Request failed (${response.status})`;
try {
+6
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_API_BASE_URL, API_URL_KEY } from '@/services/auth';
export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
@@ -69,6 +70,8 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
theme: ThemeColors;
}
@@ -112,6 +115,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
STORAGE_KEYS.reminderPreference,
'15m',
);
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const theme = colors;
@@ -126,6 +130,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy,
reminderPreference,
setReminderPreference,
apiUrl,
setApiUrl,
theme,
}}
>