Full UI 180 & Overall improvements
This commit is contained in:
@@ -1,9 +1,72 @@
|
||||
import React from 'react';
|
||||
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 ChipGroup from '../components/ChipGroup';
|
||||
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;
|
||||
@@ -21,131 +84,343 @@ export default function ItemModal({
|
||||
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="slide" transparent>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
|
||||
<View style={styles.modalCard}>
|
||||
<View style={styles.sectionRow}>
|
||||
<Text style={styles.sectionTitle}>{itemForm.id ? 'Update Item' : 'Add Item'}</Text>
|
||||
<Pressable onPress={() => setItemModalVisible(false)}>
|
||||
<Text style={styles.closeText}>Close</Text>
|
||||
<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={{ paddingBottom: 12 }}
|
||||
contentContainerStyle={styles.modalScrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Field label="Name">
|
||||
<Field label="Name" icon="text-outline">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
ref={nameInputRef}
|
||||
className={styles.input}
|
||||
value={itemForm.name}
|
||||
onChangeText={(v) => updateItemForm('name', v)}
|
||||
placeholder="Toothbrush"
|
||||
placeholderTextColor="#6b7280"
|
||||
placeholderTextColor="#71717a"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => quantityInputRef.current?.focus?.()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Description">
|
||||
<Field label="Quantity" icon="albums-outline">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
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="#6b7280"
|
||||
placeholderTextColor="#71717a"
|
||||
returnKeyType={itemForm.status === 'lent-to' ? 'next' : 'done'}
|
||||
onSubmitEditing={() => {
|
||||
if (itemForm.status === 'lent-to') {
|
||||
lentToInputRef.current?.focus?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Category">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={itemForm.category}
|
||||
onChangeText={(v) => updateItemForm('category', v)}
|
||||
placeholder="toiletries"
|
||||
placeholderTextColor="#6b7280"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<ChipGroup options={ITEM_STATUSES} value={itemForm.status} onChange={(v) => updateItemForm('status', v)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Placement">
|
||||
<ChipGroup options={ITEM_PLACEMENTS} value={itemForm.placement} onChange={(v) => updateItemForm('placement', v)} />
|
||||
</Field>
|
||||
|
||||
{itemForm.placement === 'other' ? (
|
||||
<Field label="Custom location">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={itemForm.placementCustom}
|
||||
onChangeText={(v) => updateItemForm('placementCustom', v)}
|
||||
placeholder="bath-kit"
|
||||
placeholderTextColor="#6b7280"
|
||||
<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')}
|
||||
/>
|
||||
{previousCustomPlacements?.length ? (
|
||||
<View style={styles.chipGroup}>
|
||||
{previousCustomPlacements.slice(0, 6).map((location) => (
|
||||
<Pressable key={location} style={styles.chip} onPress={() => updateItemForm('placementCustom', location)}>
|
||||
<Text style={styles.chipText}>{location}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
)}
|
||||
</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>
|
||||
) : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<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">
|
||||
<Field label="Lent to" icon="person-outline">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
ref={lentToInputRef}
|
||||
className={styles.input}
|
||||
value={itemForm.lentTo}
|
||||
onChangeText={(v) => updateItemForm('lentTo', v)}
|
||||
placeholder="Person name"
|
||||
placeholderTextColor="#6b7280"
|
||||
placeholderTextColor="#71717a"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="Image optimization">
|
||||
<View style={styles.chipGroup}>
|
||||
{['low', 'balanced', 'high'].map((level) => {
|
||||
const active = itemForm.imageQuality === level;
|
||||
return (
|
||||
<Pressable key={level} style={[styles.chip, active && styles.chipActive]} onPress={() => updateItemForm('imageQuality', level)}>
|
||||
<Text style={[styles.chipText, active && styles.chipTextActive]}>{level}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Field>
|
||||
|
||||
<Pressable style={styles.inlineToggle} onPress={() => updateItemForm('imageAllowCrop', !itemForm.imageAllowCrop)}>
|
||||
<Text style={styles.inlineToggleText}>{itemForm.imageAllowCrop ? '☑' : '☐'} Enable optional crop before save</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Pressable style={[styles.secondaryBtnTight, styles.flex]} onPress={() => takeItemImage(mediaOptions)}>
|
||||
<Text style={styles.secondaryBtnText}>Take photo</Text>
|
||||
<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 style={[styles.secondaryBtnTight, styles.flex]} onPress={() => pickItemImage(mediaOptions)}>
|
||||
<Text style={styles.secondaryBtnText}>{itemForm.imageUri ? 'From gallery (change)' : 'From gallery'}</Text>
|
||||
<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 }} style={styles.previewImageSmall} />}
|
||||
|
||||
<Pressable style={styles.primaryBtn} onPress={saveItemFromModal}>
|
||||
<Text style={styles.primaryBtnText}>{itemForm.id ? 'Save Changes' : 'Add Item'}</Text>
|
||||
{!!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>
|
||||
|
||||
Reference in New Issue
Block a user