working backend connectivity
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user