Full UI 180 & Overall improvements
Some checks failed
Luggage List Build / build-web (push) Successful in 31s
Luggage List Build / build-android (push) Failing after 1m24s
Luggage List Build / release (push) Has been skipped

This commit is contained in:
Space-Banane
2026-04-19 00:12:16 +02:00
parent 0057290055
commit 0a8444700e
45 changed files with 9468 additions and 1390 deletions

View File

@@ -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>