430 lines
16 KiB
JavaScript
430 lines
16 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Image, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
|
import { ITEM_PLACEMENTS, ITEM_STATUSES } from '../constants';
|
|
import Field from '../components/Field';
|
|
import { styles } from '../styles';
|
|
import { cn } from '../utils/cn';
|
|
|
|
const CATEGORY_OPTIONS = ['toiletries', 'electronics', 'documents', 'outfits', 'accessories', 'other'];
|
|
const PRESET_CATEGORIES = CATEGORY_OPTIONS.filter((option) => option !== 'other');
|
|
const IMAGE_QUALITY_OPTIONS = ['low', 'balanced', 'high'];
|
|
|
|
function normalizeValue(value) {
|
|
return (value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function optionLabel(value) {
|
|
return (value || '').replace(/-/g, ' ');
|
|
}
|
|
|
|
function SelectField({ value, placeholder, onPress }) {
|
|
return (
|
|
<Pressable className={styles.selectTrigger} onPress={onPress}>
|
|
<Text className={cn(styles.selectTriggerText, !value && styles.selectTriggerPlaceholder)}>{value ? optionLabel(value) : placeholder}</Text>
|
|
<Ionicons name="chevron-down" size={16} color="#a1a1aa" />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function SelectPopupModal({ visible, title, options, value, onSelect, onClose }) {
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
|
<View className={styles.selectModalBackdrop}>
|
|
<Pressable className={styles.selectModalBackdropHit} onPress={onClose} />
|
|
|
|
<View className={styles.selectModalCard}>
|
|
<View className={styles.sectionHeader}>
|
|
<Text className={styles.cardTitle}>{title}</Text>
|
|
<Pressable className={styles.secondaryBtnTight} onPress={onClose}>
|
|
<View className={styles.buttonContent}>
|
|
<Ionicons name="close" size={14} color="#f4f4f5" />
|
|
<Text className={styles.secondaryBtnText}>Close</Text>
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View className={styles.selectMenu}>
|
|
{options.map((option, index) => {
|
|
const active = value === option;
|
|
const isLast = index === options.length - 1;
|
|
return (
|
|
<Pressable
|
|
key={option}
|
|
className={cn(styles.selectMenuItem, active && styles.selectMenuItemActive, isLast && styles.selectMenuItemLast)}
|
|
onPress={() => onSelect(option)}
|
|
>
|
|
<Text className={cn(styles.selectMenuItemText, active && styles.selectMenuItemTextActive)}>{optionLabel(option)}</Text>
|
|
<Ionicons name={active ? 'checkmark-circle' : 'ellipse-outline'} size={16} color={active ? '#93c5fd' : '#71717a'} />
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function qualityValue(level) {
|
|
if (level === 'high') return 0.95;
|
|
if (level === 'low') return 0.45;
|
|
return 0.75;
|
|
}
|
|
|
|
export default function ItemModal({
|
|
visible,
|
|
itemForm,
|
|
previousCustomPlacements,
|
|
setItemModalVisible,
|
|
updateItemForm,
|
|
pickItemImage,
|
|
takeItemImage,
|
|
saveItemFromModal,
|
|
}) {
|
|
const [categoryCustomMode, setCategoryCustomMode] = useState(false);
|
|
const [placementCustomMode, setPlacementCustomMode] = useState(false);
|
|
const [openPicker, setOpenPicker] = useState(null);
|
|
const nameInputRef = useRef(null);
|
|
const quantityInputRef = useRef(null);
|
|
const descriptionInputRef = useRef(null);
|
|
const lentToInputRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setOpenPicker(null);
|
|
return;
|
|
}
|
|
|
|
const normalizedCategory = normalizeValue(itemForm.category);
|
|
const customCategory = !!normalizedCategory && !PRESET_CATEGORIES.includes(normalizedCategory);
|
|
setCategoryCustomMode(customCategory);
|
|
setPlacementCustomMode(itemForm.placement === 'other');
|
|
setOpenPicker(null);
|
|
}, [visible, itemForm.id]);
|
|
|
|
useEffect(() => {
|
|
if (!visible || itemForm.id) return undefined;
|
|
|
|
const timeout = setTimeout(() => {
|
|
nameInputRef.current?.focus?.();
|
|
}, 80);
|
|
|
|
return () => clearTimeout(timeout);
|
|
}, [visible, itemForm.id]);
|
|
|
|
const mediaOptions = {
|
|
quality: qualityValue(itemForm.imageQuality),
|
|
allowCrop: !!itemForm.imageAllowCrop,
|
|
};
|
|
|
|
const normalizedCategory = normalizeValue(itemForm.category);
|
|
const categorySelectValue = PRESET_CATEGORIES.includes(normalizedCategory) ? normalizedCategory : '';
|
|
const savedPlacementOptions = (previousCustomPlacements || []).slice(0, 6);
|
|
const savedPlacementValue = savedPlacementOptions.includes(itemForm.placementCustom) ? itemForm.placementCustom : '';
|
|
|
|
const pickerConfig = {
|
|
category: {
|
|
title: 'Select category',
|
|
options: CATEGORY_OPTIONS,
|
|
value: categorySelectValue,
|
|
onSelect: chooseCategory,
|
|
},
|
|
status: {
|
|
title: 'Select status',
|
|
options: ITEM_STATUSES,
|
|
value: itemForm.status,
|
|
onSelect: chooseStatus,
|
|
},
|
|
placement: {
|
|
title: 'Select placement',
|
|
options: ITEM_PLACEMENTS,
|
|
value: itemForm.placement,
|
|
onSelect: choosePlacement,
|
|
},
|
|
savedPlacement: {
|
|
title: 'Saved custom locations',
|
|
options: savedPlacementOptions,
|
|
value: savedPlacementValue,
|
|
onSelect: chooseSavedPlacement,
|
|
},
|
|
imageQuality: {
|
|
title: 'Image quality',
|
|
options: IMAGE_QUALITY_OPTIONS,
|
|
value: itemForm.imageQuality,
|
|
onSelect: chooseImageQuality,
|
|
},
|
|
};
|
|
|
|
const activePicker = openPicker ? pickerConfig[openPicker] : null;
|
|
|
|
function chooseCategory(value) {
|
|
if (value === 'other') {
|
|
setCategoryCustomMode(true);
|
|
if (PRESET_CATEGORIES.includes(normalizedCategory)) {
|
|
updateItemForm('category', '');
|
|
}
|
|
setOpenPicker(null);
|
|
return;
|
|
}
|
|
|
|
setCategoryCustomMode(false);
|
|
updateItemForm('category', value);
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function chooseStatus(value) {
|
|
updateItemForm('status', value);
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function choosePlacement(value) {
|
|
if (value === 'other') {
|
|
setPlacementCustomMode(true);
|
|
updateItemForm('placement', 'other');
|
|
setOpenPicker(null);
|
|
return;
|
|
}
|
|
|
|
setPlacementCustomMode(false);
|
|
updateItemForm('placement', value);
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function chooseSavedPlacement(value) {
|
|
updateItemForm('placementCustom', value);
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function chooseImageQuality(value) {
|
|
updateItemForm('imageQuality', value);
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function resetCategorySelector() {
|
|
setCategoryCustomMode(false);
|
|
updateItemForm('category', '');
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
function resetPlacementSelector() {
|
|
setPlacementCustomMode(false);
|
|
updateItemForm('placement', ITEM_PLACEMENTS[0] || 'suitcase');
|
|
setOpenPicker(null);
|
|
}
|
|
|
|
return (
|
|
<Modal visible={visible} animationType="none" transparent>
|
|
<View className={styles.modalBackdrop}>
|
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} className={styles.modalKeyboardWrap}>
|
|
<View className={styles.modalCard}>
|
|
<View className={styles.sectionHeader}>
|
|
<View className={styles.sectionHeaderLeft}>
|
|
<View className={styles.sectionHeaderIconWrap}>
|
|
<Ionicons name={itemForm.id ? 'create-outline' : 'add-circle-outline'} size={16} color="#d4d4d8" />
|
|
</View>
|
|
<Text className={styles.cardTitle}>{itemForm.id ? 'Update Item' : 'Add Item'}</Text>
|
|
</View>
|
|
<Pressable className={styles.secondaryBtnTight} onPress={() => setItemModalVisible(false)}>
|
|
<View className={styles.buttonContent}>
|
|
<Ionicons name="close" size={14} color="#f4f4f5" />
|
|
<Text className={styles.secondaryBtnText}>Close</Text>
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<ScrollView
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="interactive"
|
|
contentContainerStyle={styles.modalScrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<Field label="Name" icon="text-outline">
|
|
<TextInput
|
|
ref={nameInputRef}
|
|
className={styles.input}
|
|
value={itemForm.name}
|
|
onChangeText={(v) => updateItemForm('name', v)}
|
|
placeholder="Toothbrush"
|
|
placeholderTextColor="#71717a"
|
|
returnKeyType="next"
|
|
blurOnSubmit={false}
|
|
onSubmitEditing={() => quantityInputRef.current?.focus?.()}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Quantity" icon="albums-outline">
|
|
<TextInput
|
|
ref={quantityInputRef}
|
|
className={styles.input}
|
|
value={`${itemForm.quantity || 1}`}
|
|
onChangeText={(v) => {
|
|
const numeric = (v || '').replace(/[^0-9]/g, '');
|
|
updateItemForm('quantity', numeric ? Math.max(1, Number.parseInt(numeric, 10)) : 1);
|
|
}}
|
|
placeholder="1"
|
|
placeholderTextColor="#71717a"
|
|
keyboardType="number-pad"
|
|
returnKeyType="next"
|
|
blurOnSubmit={false}
|
|
onSubmitEditing={() => descriptionInputRef.current?.focus?.()}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Description" icon="document-text-outline">
|
|
<TextInput
|
|
ref={descriptionInputRef}
|
|
className={styles.input}
|
|
value={itemForm.description}
|
|
onChangeText={(v) => updateItemForm('description', v)}
|
|
placeholder="Optional"
|
|
placeholderTextColor="#71717a"
|
|
returnKeyType={itemForm.status === 'lent-to' ? 'next' : 'done'}
|
|
onSubmitEditing={() => {
|
|
if (itemForm.status === 'lent-to') {
|
|
lentToInputRef.current?.focus?.();
|
|
}
|
|
}}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Category" icon="layers-outline">
|
|
{categoryCustomMode ? (
|
|
<View className={styles.selectCustomRow}>
|
|
<TextInput
|
|
className={cn(styles.input, styles.flex)}
|
|
value={itemForm.category}
|
|
onChangeText={(v) => updateItemForm('category', v)}
|
|
placeholder="Custom category"
|
|
placeholderTextColor="#71717a"
|
|
/>
|
|
<Pressable className={styles.selectCustomResetBtn} onPress={resetCategorySelector}>
|
|
<Ionicons name="refresh-outline" size={16} color="#d4d4d8" />
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<SelectField
|
|
value={categorySelectValue}
|
|
placeholder="Select category"
|
|
onPress={() => setOpenPicker('category')}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
<Field label="Status" icon="radio-button-on-outline">
|
|
<SelectField
|
|
value={itemForm.status}
|
|
placeholder="Select status"
|
|
onPress={() => setOpenPicker('status')}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Placement" icon="pin-outline">
|
|
{placementCustomMode ? (
|
|
<View className={styles.fieldWrap}>
|
|
<View className={styles.selectCustomRow}>
|
|
<TextInput
|
|
className={cn(styles.input, styles.flex)}
|
|
value={itemForm.placementCustom}
|
|
onChangeText={(v) => updateItemForm('placementCustom', v)}
|
|
placeholder="bath-kit"
|
|
placeholderTextColor="#71717a"
|
|
/>
|
|
<Pressable className={styles.selectCustomResetBtn} onPress={resetPlacementSelector}>
|
|
<Ionicons name="refresh-outline" size={16} color="#d4d4d8" />
|
|
</Pressable>
|
|
</View>
|
|
<Text className={styles.cardMeta}>Custom location</Text>
|
|
|
|
{savedPlacementOptions.length ? (
|
|
<SelectField
|
|
value={savedPlacementValue}
|
|
placeholder="Use saved custom location"
|
|
onPress={() => setOpenPicker('savedPlacement')}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
) : (
|
|
<SelectField
|
|
value={itemForm.placement}
|
|
placeholder="Select placement"
|
|
onPress={() => setOpenPicker('placement')}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
{itemForm.status === 'lent-to' ? (
|
|
<Field label="Lent to" icon="person-outline">
|
|
<TextInput
|
|
ref={lentToInputRef}
|
|
className={styles.input}
|
|
value={itemForm.lentTo}
|
|
onChangeText={(v) => updateItemForm('lentTo', v)}
|
|
placeholder="Person name"
|
|
placeholderTextColor="#71717a"
|
|
returnKeyType="done"
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
|
|
<View className={styles.actionRow}>
|
|
<Pressable className={cn(styles.secondaryBtnTight, styles.flex)} onPress={() => takeItemImage(mediaOptions)}>
|
|
<View className={styles.buttonContent}>
|
|
<Ionicons name="camera-outline" size={14} color="#f4f4f5" />
|
|
<Text className={styles.secondaryBtnText}>Take photo</Text>
|
|
</View>
|
|
</Pressable>
|
|
<Pressable className={cn(styles.secondaryBtnTight, styles.flex)} onPress={() => pickItemImage(mediaOptions)}>
|
|
<View className={styles.buttonContent}>
|
|
<Ionicons name="images-outline" size={14} color="#f4f4f5" />
|
|
<Text className={styles.secondaryBtnText}>{itemForm.imageUri ? 'From gallery (change)' : 'From gallery'}</Text>
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{!!itemForm.imageUri ? (
|
|
<>
|
|
<Image source={{ uri: itemForm.imageUri }} className={styles.previewImageSmall} />
|
|
|
|
<Field label="Image optimization" icon="image-outline">
|
|
<SelectField
|
|
value={itemForm.imageQuality}
|
|
placeholder="Select quality"
|
|
onPress={() => setOpenPicker('imageQuality')}
|
|
/>
|
|
</Field>
|
|
|
|
<Pressable className={styles.inlineToggle} onPress={() => updateItemForm('imageAllowCrop', !itemForm.imageAllowCrop)}>
|
|
<View className={styles.sectionHeaderLeft}>
|
|
<Ionicons name={itemForm.imageAllowCrop ? 'checkbox-outline' : 'square-outline'} size={16} color="#d4d4d8" />
|
|
<Text className={styles.inlineToggleText}>Enable optional crop before save</Text>
|
|
</View>
|
|
</Pressable>
|
|
</>
|
|
) : null}
|
|
|
|
<Pressable className={styles.primaryBtn} onPress={saveItemFromModal}>
|
|
<View className={styles.buttonContent}>
|
|
<Ionicons name="checkmark-circle-outline" size={15} color="#ffffff" />
|
|
<Text className={styles.primaryBtnText}>{itemForm.id ? 'Save Changes' : 'Add Item'}</Text>
|
|
</View>
|
|
</Pressable>
|
|
</ScrollView>
|
|
|
|
<SelectPopupModal
|
|
visible={!!activePicker}
|
|
title={activePicker?.title || 'Select option'}
|
|
options={activePicker?.options || []}
|
|
value={activePicker?.value || ''}
|
|
onSelect={(value) => activePicker?.onSelect?.(value)}
|
|
onClose={() => setOpenPicker(null)}
|
|
/>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|