diff --git a/carry-your-live/app.json b/carry-your-live/app.json index ecce1c7..efc663f 100644 --- a/carry-your-live/app.json +++ b/carry-your-live/app.json @@ -10,6 +10,7 @@ "supportsTablet": true }, "android": { + "softwareKeyboardLayoutMode": "resize", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/android-icon-foreground.png", diff --git a/carry-your-live/app/(tabs)/_layout.tsx b/carry-your-live/app/(tabs)/_layout.tsx index fe7920f..8706552 100644 --- a/carry-your-live/app/(tabs)/_layout.tsx +++ b/carry-your-live/app/(tabs)/_layout.tsx @@ -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 ( (null); const [editingCategory, setEditingCategory] = useState(null); const [syncVisible, setSyncVisible] = useState(false); const [friendsVisible, setFriendsVisible] = useState(false); const [legalVisible, setLegalVisible] = useState(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 /> + Server + setServerUrlVisible(true)} + showChevron + /> About setFriendsVisible(false)} /> + setServerUrlVisible(false)} /> + - - + + + {category ? 'Edit Category' : 'New Category'} @@ -129,8 +133,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose Save - - + + ); } diff --git a/carry-your-live/src/components/CategoryFilter.tsx b/carry-your-live/src/components/CategoryFilter.tsx index 0263a02..a72c79e 100644 --- a/carry-your-live/src/components/CategoryFilter.tsx +++ b/carry-your-live/src/components/CategoryFilter.tsx @@ -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', }, diff --git a/carry-your-live/src/components/ColorPickerInput.native.tsx b/carry-your-live/src/components/ColorPickerInput.native.tsx index f9ba4da..5aab3be 100644 --- a/carry-your-live/src/components/ColorPickerInput.native.tsx +++ b/carry-your-live/src/components/ColorPickerInput.native.tsx @@ -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 ( - - onChange(normalizeHex(text))} - placeholder="#E53935" - placeholderTextColor={theme.textMuted} - autoCapitalize="characters" - autoCorrect={false} - maxLength={7} - /> + + + # + + Hex code ); @@ -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, diff --git a/carry-your-live/src/components/ColorPickerInput.web.tsx b/carry-your-live/src/components/ColorPickerInput.web.tsx index b064456..cb1166d 100644 --- a/carry-your-live/src/components/ColorPickerInput.web.tsx +++ b/carry-your-live/src/components/ColorPickerInput.web.tsx @@ -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 ( {React.createElement('input', { type: 'color', - value: value.toUpperCase(), - onChange: (e: React.ChangeEvent) => onChange(e.target.value), + value: normalized, + onChange: (e: React.ChangeEvent) => { + 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', }, })} - {value.toUpperCase()} + {normalized} ); } diff --git a/carry-your-live/src/components/FriendsModal.tsx b/carry-your-live/src/components/FriendsModal.tsx index df13dba..bf8fc38 100644 --- a/carry-your-live/src/components/FriendsModal.tsx +++ b/carry-your-live/src/components/FriendsModal.tsx @@ -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 ( - + Friends diff --git a/carry-your-live/src/components/Header.tsx b/carry-your-live/src/components/Header.tsx index 6ace18f..19cb8e7 100644 --- a/carry-your-live/src/components/Header.tsx +++ b/carry-your-live/src/components/Header.tsx @@ -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 ( - + {showLogo && ( @@ -23,8 +22,7 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) { {title} {rightAction} - - + ); } @@ -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, }, }); \ No newline at end of file diff --git a/carry-your-live/src/components/QuickAddBar.tsx b/carry-your-live/src/components/QuickAddBar.tsx index 27c6986..1fe11b8 100644 --- a/carry-your-live/src/components/QuickAddBar.tsx +++ b/carry-your-live/src/components/QuickAddBar.tsx @@ -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 ( 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 ( + + + + + Backend URL + + + + + + + + API base URL + + + Include the /api suffix, e.g. https://example.com/api + + + + + Cancel + + + Save + + + + + + ); +} + +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', + }, +}); diff --git a/carry-your-live/src/components/SyncModal.tsx b/carry-your-live/src/components/SyncModal.tsx index 56af092..7c3749d 100644 --- a/carry-your-live/src/components/SyncModal.tsx +++ b/carry-your-live/src/components/SyncModal.tsx @@ -116,7 +116,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) { diff --git a/carry-your-live/src/components/TaskList.tsx b/carry-your-live/src/components/TaskList.tsx index e972a7d..3e1617e 100644 --- a/carry-your-live/src/components/TaskList.tsx +++ b/carry-your-live/src/components/TaskList.tsx @@ -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: { diff --git a/carry-your-live/src/services/auth.ts b/carry-your-live/src/services/auth.ts index 58418c2..37e6137 100644 --- a/carry-your-live/src/services/auth.ts +++ b/carry-your-live/src/services/auth.ts @@ -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 { + 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 { + 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 { export async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = await getAuthToken(); + const apiBaseUrl = await getApiBaseUrl(); const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record | 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 { diff --git a/carry-your-live/src/theme.tsx b/carry-your-live/src/theme.tsx index b13a6fa..5cdb338 100644 --- a/carry-your-live/src/theme.tsx +++ b/carry-your-live/src/theme.tsx @@ -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(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, }} >