* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,434 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
createIngredientCatalogItem,
deleteIngredientCatalogItem,
fetchIngredientCatalogItems,
fetchIngredientCategories,
updateIngredientCatalogItem,
} from '@/src/api/ingredient-catalog.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import type {
IngredientCategory,
IngredientNutritionItem,
UpsertIngredientNutritionItemInput,
} from '@/src/types/ingredient-catalog';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type IngredientSortColumn = 'name' | 'category' | 'calories' | 'protein' | 'fat' | 'carbs' | 'fiber';
const EMPTY_FORM: UpsertIngredientNutritionItemInput = {
categoryId: 0,
name: '',
namePl: '',
caloriesPer100G: 0,
proteinGPer100G: 0,
fatGPer100G: 0,
carbsGPer100G: 0,
fiberGPer100G: null,
sortOrder: 0,
isActive: true,
};
export default function IngredientsScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<UpsertIngredientNutritionItemInput>({ ...EMPTY_FORM });
const [formError, setFormError] = useState<string | null>(null);
const [sort, setSort] = useState<{ column: IngredientSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
const categoriesQuery = useQuery({
queryKey: ['ingredient-categories'],
queryFn: fetchIngredientCategories,
});
const itemsQuery = useQuery({
queryKey: ['ingredient-catalog', category, debouncedSearch],
queryFn: () =>
fetchIngredientCatalogItems({
category: category || undefined,
search: debouncedSearch || undefined,
}),
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
...form,
name: form.name.trim(),
namePl: form.namePl?.trim() || null,
fiberGPer100G: form.fiberGPer100G ?? null,
};
if (editingId) return updateIngredientCatalogItem(editingId, payload);
return createIngredientCatalogItem(payload);
},
onSuccess: () => {
setShowForm(false);
setEditingId(null);
setFormError(null);
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
},
onError: (err) => {
setFormError(extractApiError(err, t('errors.saveIngredientFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: deleteIngredientCatalogItem,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['ingredient-categories'] });
void queryClient.invalidateQueries({ queryKey: ['ingredient-catalog'] });
},
});
const categoryLabel = (code: string, fallback: string) =>
t(`ingredientCatalog.categories.${code}`, { defaultValue: fallback });
const totalCount = useMemo(
() => (categoriesQuery.data ?? []).reduce((sum, c) => sum + c.itemCount, 0),
[categoriesQuery.data],
);
const openCreateForm = () => {
setEditingId(null);
setForm({ ...EMPTY_FORM, categoryId: categoriesQuery.data?.[0]?.id ?? 0 });
setFormError(null);
setShowForm(true);
};
const openEditForm = (item: IngredientNutritionItem) => {
setEditingId(item.id);
setForm({
categoryId: item.categoryId,
name: item.name,
namePl: item.namePl ?? '',
caloriesPer100G: item.caloriesPer100G,
proteinGPer100G: item.proteinGPer100G,
fatGPer100G: item.fatGPer100G,
carbsGPer100G: item.carbsGPer100G,
fiberGPer100G: item.fiberGPer100G,
sortOrder: 0,
isActive: true,
});
setFormError(null);
setShowForm(true);
};
const saveForm = () => {
if (!form.categoryId || !form.name.trim()) {
setFormError(t('ingredientCatalog.formInvalid'));
return;
}
saveMutation.mutate();
};
const deleteItem = (id: number) => {
Alert.alert(t('common.delete'), t('ingredientCatalog.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(id) },
]);
};
const loading = categoriesQuery.isLoading || itemsQuery.isLoading;
const error = categoriesQuery.error ?? itemsQuery.error;
const items = itemsQuery.data ?? [];
const sortedItems = useMemo(() => {
const { column, direction } = sort;
return sortBy(items, direction, (item) => {
switch (column) {
case 'name':
return ingredientDisplayName(item, i18n.language);
case 'category':
return item.categoryCode;
case 'calories':
return item.caloriesPer100G;
case 'protein':
return Number(item.proteinGPer100G);
case 'fat':
return Number(item.fatGPer100G);
case 'carbs':
return Number(item.carbsGPer100G);
case 'fiber':
return item.fiberGPer100G != null ? Number(item.fiberGPer100G) : null;
}
});
}, [items, sort, i18n.language]);
const toggleSort = (column: IngredientSortColumn) => {
setSort((current) => nextSort(current, column));
};
return (
<Screen
title={t('ingredientCatalog.title')}
subtitle={t('ingredientCatalog.subtitle')}
loading={loading}
>
<Button title={`+ ${t('ingredientCatalog.addIngredient')}`} variant="secondary" onPress={openCreateForm} />
{showForm ? (
<View style={[styles.formBox, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={[styles.formTitle, { color: colors.text }]}>
{editingId ? t('ingredientCatalog.editIngredient') : t('ingredientCatalog.addIngredient')}
</Text>
{formError ? <Text style={{ color: '#dc2626', marginBottom: 8 }}>{formError}</Text> : null}
<Text style={[styles.label, { color: colors.text }]}>{t('ingredientCatalog.columns.category')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
<Chip
key={cat.id}
label={categoryLabel(cat.code, cat.name)}
active={form.categoryId === cat.id}
onPress={() => setForm((prev) => ({ ...prev, categoryId: cat.id }))}
/>
))}
</ScrollView>
<Input
label={t('ingredientCatalog.nameEn')}
value={form.name}
onChangeText={(v) => setForm((prev) => ({ ...prev, name: v }))}
/>
<Input
label={t('ingredientCatalog.namePlLabel')}
value={form.namePl ?? ''}
onChangeText={(v) => setForm((prev) => ({ ...prev, namePl: v }))}
/>
{(
[
['caloriesPer100G', 'ingredientCatalog.columns.calories'],
['proteinGPer100G', 'ingredientCatalog.columns.protein'],
['fatGPer100G', 'ingredientCatalog.columns.fat'],
['carbsGPer100G', 'ingredientCatalog.columns.carbs'],
['fiberGPer100G', 'ingredientCatalog.columns.fiber'],
] as const
).map(([key, labelKey]) => (
<Input
key={key}
label={t(labelKey)}
keyboardType="decimal-pad"
value={form[key]?.toString() ?? ''}
onChangeText={(v) =>
setForm((prev) => ({
...prev,
[key]: v === '' ? null : Number(v),
}))
}
/>
))}
<View style={styles.formActions}>
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={saveForm} />
<Button title={t('common.cancel')} variant="secondary" onPress={() => setShowForm(false)} />
</View>
</View>
) : null}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Chip
label={`${t('ingredientCatalog.allCategories')} (${totalCount})`}
active={category === ''}
onPress={() => setCategory('')}
/>
{(categoriesQuery.data ?? []).map((cat: IngredientCategory) => (
<Chip
key={cat.code}
label={`${categoryLabel(cat.code, cat.name)} (${cat.itemCount})`}
active={category === cat.code}
onPress={() => setCategory(cat.code)}
/>
))}
</ScrollView>
<Input
label={t('common.search')}
value={search}
onChangeText={setSearch}
placeholder={t('ingredientCatalog.searchPlaceholder')}
/>
{(itemsQuery.data ?? []).length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{(
[
['name', 'ingredientCatalog.columns.name'],
['category', 'ingredientCatalog.columns.category'],
['calories', 'ingredientCatalog.columns.calories'],
['protein', 'ingredientCatalog.columns.protein'],
['fat', 'ingredientCatalog.columns.fat'],
['carbs', 'ingredientCatalog.columns.carbs'],
['fiber', 'ingredientCatalog.columns.fiber'],
] as const
).map(([column, labelKey]) => (
<SortChip
key={column}
label={`${t(labelKey)}${sortIndicator(sort, column)}`}
active={sort.column === column}
onPress={() => toggleSort(column)}
/>
))}
</ScrollView>
) : null}
{error ? (
<ErrorView
message={extractApiError(error, t('errors.loadIngredientCatalogFailed'))}
onRetry={() => {
categoriesQuery.refetch();
itemsQuery.refetch();
}}
/>
) : (itemsQuery.data ?? []).length === 0 ? (
<View style={styles.empty}>
<Text style={[styles.emptyTitle, { color: colors.text }]}>{t('ingredientCatalog.noResultsTitle')}</Text>
<Text style={{ color: colors.textMuted }}>{t('ingredientCatalog.noResultsDescription')}</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.list}>
{sortedItems.map((item) => (
<View
key={item.id}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={[styles.cardTitle, { color: colors.text }]}>
{ingredientDisplayName(item, i18n.language)}
</Text>
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{categoryLabel(item.categoryCode, item.categoryName)}
</Text>
</View>
<View style={styles.cardActions}>
<Pressable onPress={() => openEditForm(item)}>
<Text style={{ color: colors.brand, fontWeight: '600' }}>{t('common.edit')}</Text>
</Pressable>
<Pressable onPress={() => deleteItem(item.id)}>
<Text style={{ color: '#dc2626', fontWeight: '600' }}>{t('common.delete')}</Text>
</Pressable>
</View>
</View>
<View style={styles.macros}>
<Macro label={t('ingredientCatalog.columns.calories')} value={`${item.caloriesPer100G}`} />
<Macro label={t('ingredientCatalog.columns.protein')} value={formatNum(item.proteinGPer100G)} />
<Macro label={t('ingredientCatalog.columns.fat')} value={formatNum(item.fatGPer100G)} />
<Macro label={t('ingredientCatalog.columns.carbs')} value={formatNum(item.carbsGPer100G)} />
{item.fiberGPer100G != null ? (
<Macro label={t('ingredientCatalog.columns.fiber')} value={formatNum(item.fiberGPer100G)} />
) : null}
</View>
</View>
))}
<Text style={[styles.note, { color: colors.textMuted }]}>{t('ingredientCatalog.per100gNote')}</Text>
</ScrollView>
)}
</Screen>
);
}
function SortChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.sortChip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 12 }}>{label}</Text>
</Pressable>
);
}
function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={[
styles.chip,
{
backgroundColor: active ? colors.brand : colors.surface,
borderColor: active ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>{label}</Text>
</Pressable>
);
}
function Macro({ label, value }: { label: string; value: string }) {
const { colors } = useAppearance();
return (
<View style={styles.macro}>
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{label}</Text>
<Text style={{ color: colors.text, fontWeight: '600' }}>{value}</Text>
</View>
);
}
function formatNum(value: number): string {
return Number(value).toFixed(1);
}
const styles = StyleSheet.create({
chips: { gap: 8, paddingBottom: 12 },
sortRow: { gap: 8, paddingBottom: 8 },
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
formBox: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 8, marginBottom: 8 },
formTitle: { fontSize: 17, fontWeight: '700', marginBottom: 4 },
formActions: { flexDirection: 'row', gap: 8, marginTop: 4 },
label: { fontSize: 14, fontWeight: '600' },
list: { gap: 12, paddingBottom: 24 },
card: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
},
cardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 },
cardActions: { gap: 8, alignItems: 'flex-end' },
cardTitle: { fontSize: 16, fontWeight: '700', marginBottom: 2 },
macros: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
macro: { minWidth: 72 },
note: { fontSize: 12, textAlign: 'center', marginTop: 8 },
empty: { paddingVertical: 32, alignItems: 'center', gap: 8 },
emptyTitle: { fontSize: 16, fontWeight: '700' },
});