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 "supportsTablet": true
}, },
"android": { "android": {
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
+4 -2
View File
@@ -1,10 +1,12 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TabBarIcon } from '@/components/TabBarIcon'; import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
export default function TabLayout() { export default function TabLayout() {
const { theme } = useSettings(); const { theme } = useSettings();
const insets = useSafeAreaInsets();
return ( return (
<Tabs <Tabs
@@ -15,8 +17,8 @@ export default function TabLayout() {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: theme.border, borderTopColor: theme.border,
height: 64, height: 64 + insets.bottom,
paddingBottom: 0, paddingBottom: insets.bottom,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 11, fontSize: 11,
+13 -2
View File
@@ -7,6 +7,7 @@ import { CategoryEditorModal } from '@/components/CategoryEditorModal';
import { SyncModal } from '@/components/SyncModal'; import { SyncModal } from '@/components/SyncModal';
import { FriendsModal } from '@/components/FriendsModal'; import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal'; import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus'; import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme'; import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
@@ -16,13 +17,14 @@ import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
export default function SettingsScreen() { 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 categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null); const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null); const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false); const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false); const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null); const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...'); const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const refreshSyncStatus = async () => { const refreshSyncStatus = async () => {
@@ -136,6 +138,13 @@ export default function SettingsScreen() {
onPress={() => setFriendsVisible(true)} onPress={() => setFriendsVisible(true)}
showChevron 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> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem <ListItem
title="Version" title="Version"
@@ -191,6 +200,8 @@ export default function SettingsScreen() {
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} /> <FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<LegalModal <LegalModal
visible={legalVisible !== null} visible={legalVisible !== null}
type={legalVisible} type={legalVisible}
@@ -213,7 +224,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
content: { content: {
flex: 1, flexGrow: 1,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 8,
}, },
+1 -1
View File
@@ -172,7 +172,7 @@ export default function AddTaskScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
> >
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
+1 -1
View File
@@ -224,7 +224,7 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
> >
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; 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 Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
@@ -56,8 +56,12 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}> <KeyboardAvoidingView
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> 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}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}> <Text style={[styles.title, { color: theme.text }]}>
{category ? 'Edit Category' : 'New Category'} {category ? 'Edit Category' : 'New Category'}
@@ -129,8 +133,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
<Text style={styles.saveButtonText}>Save</Text> <Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </ScrollView>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
); );
} }
@@ -150,10 +150,11 @@ function AnimatedCategoryButton({ category, selected, onPress, theme }: Animated
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollView: { scrollView: {
paddingVertical: 8, paddingVertical: 0,
}, },
container: { container: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 4,
gap: 8, gap: 8,
alignItems: 'center', alignItems: 'center',
}, },
@@ -7,30 +7,39 @@ interface ColorPickerInputProps {
onChange: (color: string) => void; onChange: (color: string) => void;
} }
function normalizeHex(input: string): string { const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
return cleaned ? `#${cleaned}` : ''; function isValidHex(color: string): boolean {
return HEX_PATTERN.test(color);
} }
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) { export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings(); 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 ( return (
<View style={styles.row}> <View style={styles.row}>
<View style={[styles.preview, { backgroundColor: value }]} /> <View style={[styles.preview, { backgroundColor: valid ? value : theme.borderStrong, borderColor: theme.borderStrong }]} />
<TextInput <View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
style={[ <Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
styles.input, <TextInput
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }, style={[styles.input, { color: theme.text }]}
]} value={hex}
value={value} onChangeText={handleChange}
onChangeText={(text) => onChange(normalizeHex(text))} placeholder="E53935"
placeholder="#E53935" placeholderTextColor={theme.textMuted}
placeholderTextColor={theme.textMuted} autoCapitalize="characters"
autoCapitalize="characters" autoCorrect={false}
autoCorrect={false} maxLength={6}
maxLength={7} />
/> </View>
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text> <Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text>
</View> </View>
); );
@@ -47,16 +56,26 @@ const styles = StyleSheet.create({
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
borderWidth: 1, borderWidth: 1,
borderColor: '#E0E0E0',
}, },
input: { inputWrap: {
flex: 1, flex: 1,
height: 44, height: 44,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 10, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hash: {
fontSize: 15,
fontWeight: '600',
},
input: {
flex: 1,
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
paddingVertical: 0,
}, },
hint: { hint: {
fontSize: 12, fontSize: 12,
@@ -7,15 +7,23 @@ interface ColorPickerInputProps {
onChange: (color: string) => void; onChange: (color: string) => void;
} }
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) { export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const valid = HEX_PATTERN.test(value);
const normalized = valid ? value.toUpperCase() : '#000000';
return ( return (
<View style={styles.row}> <View style={styles.row}>
{React.createElement('input', { {React.createElement('input', {
type: 'color', type: 'color',
value: value.toUpperCase(), value: normalized,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value), onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (HEX_PATTERN.test(e.target.value)) {
onChange(e.target.value.toUpperCase());
}
},
style: { style: {
width: 44, width: 44,
height: 44, height: 44,
@@ -26,7 +34,7 @@ export default function WebColorPickerInput({ value, onChange }: ColorPickerInpu
cursor: 'pointer', cursor: 'pointer',
}, },
})} })}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text> <Text style={[styles.hexText, { color: theme.textSecondary }]}>{normalized}</Text>
</View> </View>
); );
} }
@@ -9,6 +9,8 @@ import {
FlatList, FlatList,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
KeyboardAvoidingView,
Platform,
} from 'react-native'; } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -90,7 +92,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <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.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text> <Text style={[styles.title, { color: theme.text }]}>Friends</Text>
+13 -15
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
interface HeaderProps { interface HeaderProps {
@@ -13,7 +12,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings(); const { theme } = useSettings();
return ( return (
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}> <View style={[styles.header, { backgroundColor: theme.background }]}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{showLogo && ( {showLogo && (
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
@@ -23,8 +22,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<View style={styles.spacer}>{rightAction}</View> <View style={styles.spacer}>{rightAction}</View>
</View> </View>
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} /> </View>
</SafeAreaView>
); );
} }
@@ -43,13 +41,13 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 8, paddingTop: 2,
paddingBottom: 16, paddingBottom: 6,
height: 80, height: 44,
}, },
logoContainer: { logoContainer: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935', backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
@@ -57,7 +55,7 @@ const styles = StyleSheet.create({
}, },
logoText: { logoText: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 20, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
title: { title: {
@@ -68,13 +66,13 @@ const styles = StyleSheet.create({
marginLeft: -30, marginLeft: -30,
}, },
spacer: { spacer: {
width: 36, width: 32,
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
bottomRounded: { bottomRounded: {
height: 24, height: 16,
borderBottomLeftRadius: 24, borderBottomLeftRadius: 16,
borderBottomRightRadius: 24, borderBottomRightRadius: 16,
marginTop: -24, marginTop: -16,
}, },
}); });
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; 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 { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
@@ -13,6 +14,7 @@ interface QuickAddBarProps {
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings(); const { theme, defaultCategoryId } = useSettings();
const insets = useSafeAreaInsets();
const categories = useCategories(); const categories = useCategories();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || ''); const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || '');
@@ -56,8 +58,8 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.wrapper} style={[styles.wrapper, { bottom: insets.bottom + 16 }]}
> >
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity <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}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.overlay} style={styles.overlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
> >
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} /> <TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <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) => { const handleDragStart = useCallback(async (taskId: string) => {
enterSelection(taskId);
dragStateRef.current = { taskId, positions: await measureItems() }; dragStateRef.current = { taskId, positions: await measureItems() };
}, [enterSelection, measureItems]); }, [measureItems]);
const handleDragUpdate = useCallback((absoluteY: number) => { const handleDragUpdate = useCallback((absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
@@ -284,11 +283,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
if (target) { if (target) {
(async () => { (async () => {
await convertTaskToSubtask(state.taskId, target); await convertTaskToSubtask(state.taskId, target);
exitSelection();
refreshAll(); refreshAll();
})(); })();
} }
}, [findHoverTarget, exitSelection, refreshAll]); }, [findHoverTarget, refreshAll]);
if (loading && !refreshing) { if (loading && !refreshing) {
return ( return (
@@ -322,11 +320,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
task={item as TaskData} task={item as TaskData}
onToggle={() => handleToggle(item.id)} onToggle={() => handleToggle(item.id)}
onDelete={() => handleDeleteOne(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)} onLongPress={selectionMode ? undefined : () => enterSelection(item.id)}
onMenuOpen={() => setMenuTaskId(item.id)} onMenuOpen={() => setMenuTaskId(item.id)}
selected={selectedIds.has(item.id)} selected={selectedIds.has(item.id)}
selectionMode={selectionMode} selectionMode={selectionMode}
expanded={isExpanded}
draggable draggable
hovered={hoverTaskId === item.id} hovered={hoverTaskId === item.id}
onDragStart={() => handleDragStart(item.id)} onDragStart={() => handleDragStart(item.id)}
@@ -517,7 +516,7 @@ const styles = StyleSheet.create({
}, },
listContent: { listContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 2,
paddingBottom: 100, paddingBottom: 100,
}, },
loadingContainer: { loadingContainer: {
+42 -2
View File
@@ -1,9 +1,48 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; 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 TOKEN_KEY = 'auth:token';
const USER_KEY = 'auth:user'; 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 { export interface AuthUser {
id: string; id: string;
@@ -47,6 +86,7 @@ export async function signOutAuth(): Promise<void> {
export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> { export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = await getAuthToken(); const token = await getAuthToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined), ...(options.headers as Record<string, string> | undefined),
@@ -54,7 +94,7 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
if (token) { if (token) {
headers.Authorization = `Bearer ${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) { if (!response.ok) {
let message = `Request failed (${response.status})`; let message = `Request failed (${response.status})`;
try { try {
+6
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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'; export type SortBy = 'date' | 'priority' | 'alpha' | 'created';
@@ -69,6 +70,8 @@ interface SettingsContextType {
setSortBy: (value: SortBy) => void; setSortBy: (value: SortBy) => void;
reminderPreference: ReminderPreference; reminderPreference: ReminderPreference;
setReminderPreference: (value: ReminderPreference) => void; setReminderPreference: (value: ReminderPreference) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
theme: ThemeColors; theme: ThemeColors;
} }
@@ -112,6 +115,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
STORAGE_KEYS.reminderPreference, STORAGE_KEYS.reminderPreference,
'15m', '15m',
); );
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
const theme = colors; const theme = colors;
@@ -126,6 +130,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setSortBy, setSortBy,
reminderPreference, reminderPreference,
setReminderPreference, setReminderPreference,
apiUrl,
setApiUrl,
theme, theme,
}} }}
> >