working backend connectivity
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,7 +56,11 @@ 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
|
||||||
|
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.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 }]}>
|
||||||
@@ -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 }]} />
|
||||||
|
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}>
|
||||||
|
<Text style={[styles.hash, { color: theme.textMuted }]}>#</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[styles.input, { color: theme.text }]}
|
||||||
styles.input,
|
value={hex}
|
||||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
|
onChangeText={handleChange}
|
||||||
]}
|
placeholder="E53935"
|
||||||
value={value}
|
|
||||||
onChangeText={(text) => onChange(normalizeHex(text))}
|
|
||||||
placeholder="#E53935"
|
|
||||||
placeholderTextColor={theme.textMuted}
|
placeholderTextColor={theme.textMuted}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
maxLength={7}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user