* 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,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

41
meal-plan-frontend-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,87 @@
# DailyMeals Mobile
Cross-platform **iOS** and **Android** client for the DailyMeals API, built with [Expo](https://expo.dev/) (~56) and [Expo Router](https://docs.expo.dev/router/introduction/).
## Features
- Sign in / register with JWT (tokens stored in SecureStore)
- Dashboard with meal categories
- Browse and search all recipes
- Recipe detail (ingredients + steps)
- Add recipes manually
- English / Polish (i18n)
- Four color themes (forest, ocean, sunset, berry)
## Prerequisites
- Node.js 20+
- [Expo Go](https://expo.dev/go) on a physical device, or Xcode (iOS Simulator) / Android Studio (emulator)
- DailyMeals API running locally (default `http://localhost:5000`)
## Setup
```bash
cd meal-plan-frontend-mobile
npm install
```
Optional API override:
```bash
export EXPO_PUBLIC_API_URL=http://192.168.1.10:5000/api
```
When unset, dev builds use:
| Platform | Default API base |
|----------|------------------|
| iOS Simulator | `http://localhost:5000/api` |
| Android Emulator | `http://10.0.2.2:5000/api` |
| Physical device | Set `EXPO_PUBLIC_API_URL` to your machine's LAN IP |
## Run
```bash
npm start
```
Then press:
- `i` — iOS Simulator
- `a` — Android Emulator
- Scan QR code — Expo Go on a phone (same network; set `EXPO_PUBLIC_API_URL`)
Or:
```bash
npm run ios
npm run android
```
## Project structure
```
app/ Expo Router screens
(auth)/ Login & register
(main)/(tabs)/ Dashboard, meals, manage, settings
(main)/recipe/[id] Recipe detail
(main)/category/[category]
src/
api/ Fetch client + API modules
context/ Auth & appearance
i18n/ EN/PL translations
theme/ Color presets
components/ Shared UI
```
## Bundle identifiers
- iOS: `com.dailymeals.mobile`
- Android: `com.dailymeals.mobile`
Configured in `app.config.ts`.
## Notes
- Excel import is available on the web clients only; mobile supports manual recipe entry.
- PDF export is not included on mobile.

View File

@@ -0,0 +1,41 @@
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'DailyMeals',
slug: 'dailymeals-mobile',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/images/icon.png',
scheme: 'dailymeals',
userInterfaceStyle: 'automatic',
ios: {
supportsTablet: true,
bundleIdentifier: 'com.dailymeals.mobile',
},
android: {
adaptiveIcon: {
backgroundColor: '#16774c',
foregroundImage: './assets/images/android-icon-foreground.png',
backgroundImage: './assets/images/android-icon-background.png',
monochromeImage: './assets/images/android-icon-monochrome.png',
},
package: 'com.dailymeals.mobile',
predictiveBackGestureEnabled: false,
},
web: {
bundler: 'metro',
output: 'static',
favicon: './assets/images/favicon.png',
},
plugins: ['expo-router', 'expo-splash-screen', 'expo-notifications', 'expo-sharing'],
experiments: {
typedRoutes: true,
},
extra: {
apiUrl: process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:5000/api',
eas: {
projectId: 'dailymeals-mobile-local',
},
},
});

View File

@@ -0,0 +1,43 @@
{
"expo": {
"name": "meal-plan-frontend-mobile",
"slug": "meal-plan-frontend-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mealplanfrontendmobile",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@@ -0,0 +1,10 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
</Stack>
);
}

View File

@@ -0,0 +1,117 @@
import { Link, router } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
export default function LoginScreen() {
const { t } = useTranslation();
const { login } = useAuth();
const { colors } = useAppearance();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setError(null);
if (!email.trim()) {
setError(t('validation.emailRequired'));
return;
}
if (!password) {
setError(t('validation.passwordRequired'));
return;
}
setLoading(true);
try {
await login({ email: email.trim(), password });
router.replace('/(main)/(tabs)');
} catch (err) {
setError(extractApiError(err, t('errors.signInFailed')));
} finally {
setLoading(false);
}
}
return (
<Screen>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={[styles.brand, { color: colors.brand }]}>{t('app.name')}</Text>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{t('auth.signInSubtitle')}</Text>
<View style={styles.form}>
<Input
label={t('auth.email')}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChangeText={setEmail}
/>
<Input
label={t('auth.password')}
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={loading ? t('auth.signingIn') : t('auth.signIn')}
loading={loading}
onPress={handleSubmit}
/>
</View>
<Text style={[styles.footer, { color: colors.textMuted }]}>
{t('auth.noAccount')}{' '}
<Link href="/(auth)/register" style={{ color: colors.brand, fontWeight: '600' }}>
{t('auth.createOne')}
</Link>
</Text>
</ScrollView>
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
gap: 16,
paddingVertical: 24,
},
brand: {
fontSize: 34,
fontWeight: '800',
},
subtitle: {
fontSize: 16,
lineHeight: 24,
},
form: {
gap: 14,
marginTop: 8,
},
error: {
fontSize: 14,
},
footer: {
textAlign: 'center',
fontSize: 15,
},
});

View File

@@ -0,0 +1,145 @@
import { Link, router } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
function validatePassword(password: string, t: (key: string) => string): string | null {
if (password.length < 8) return t('validation.passwordMinLength');
if (!/[A-Z]/.test(password)) return t('validation.passwordUppercase');
if (!/[a-z]/.test(password)) return t('validation.passwordLowercase');
if (!/\d/.test(password)) return t('validation.passwordDigit');
return null;
}
export default function RegisterScreen() {
const { t } = useTranslation();
const { register } = useAuth();
const { colors } = useAppearance();
const [email, setEmail] = useState('');
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit() {
setError(null);
if (!email.trim()) {
setError(t('validation.emailRequired'));
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
setError(t('validation.emailInvalid'));
return;
}
const passwordError = validatePassword(password, t);
if (passwordError) {
setError(passwordError);
return;
}
if (!confirmPassword) {
setError(t('validation.confirmPasswordRequired'));
return;
}
if (password !== confirmPassword) {
setError(t('validation.passwordsMismatch'));
return;
}
setLoading(true);
try {
await register({
email: email.trim(),
password,
userName: userName.trim() || undefined,
});
router.replace('/(main)/(tabs)');
} catch (err) {
setError(extractApiError(err, t('errors.registerFailed')));
} finally {
setLoading(false);
}
}
return (
<Screen title={t('auth.createAccountTitle')} subtitle={t('auth.createAccountSubtitle')}>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<View style={styles.form}>
<Input
label={t('auth.email')}
autoCapitalize="none"
keyboardType="email-address"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChangeText={setEmail}
/>
<Input
label={t('auth.displayName')}
placeholder={t('auth.displayNamePlaceholder')}
value={userName}
onChangeText={setUserName}
/>
<Input
label={t('auth.password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Text style={[styles.hint, { color: colors.textMuted }]}>{t('auth.passwordHint')}</Text>
<Input
label={t('auth.confirmPassword')}
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={loading ? t('auth.creatingAccount') : t('auth.createAccount')}
loading={loading}
onPress={handleSubmit}
/>
</View>
<Text style={[styles.footer, { color: colors.textMuted }]}>
{t('auth.hasAccount')}{' '}
<Link href="/(auth)/login" style={{ color: colors.brand, fontWeight: '600' }}>
{t('auth.signIn')}
</Link>
</Text>
</ScrollView>
</KeyboardAvoidingView>
</Screen>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
gap: 16,
paddingBottom: 24,
},
form: {
gap: 14,
},
hint: {
fontSize: 13,
marginTop: -6,
},
error: {
fontSize: 14,
},
footer: {
textAlign: 'center',
fontSize: 15,
},
});

View File

@@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { Tabs } from 'expo-router';
import { useTranslation } from 'react-i18next';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { useAppearance } from '@/src/context/AppearanceContext';
function TabIcon(props: { name: ComponentProps<typeof FontAwesome>['name']; color: string }) {
return <FontAwesome size={22} style={{ marginBottom: -2 }} name={props.name} color={props.color} />;
}
export default function TabLayout() {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.brand,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
},
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
>
<Tabs.Screen
name="index"
options={{
title: t('nav.dashboard'),
tabBarIcon: ({ color }) => <TabIcon name="home" color={String(color)} />,
}}
/>
<Tabs.Screen
name="meals"
options={{
title: t('nav.allMeals'),
tabBarIcon: ({ color }) => <TabIcon name="list" color={String(color)} />,
}}
/>
<Tabs.Screen
name="diet"
options={{
title: t('nav.dietTracker'),
tabBarIcon: ({ color }) => <TabIcon name="heartbeat" color={String(color)} />,
}}
/>
<Tabs.Screen
name="planner"
options={{
title: t('nav.planner'),
tabBarIcon: ({ color }) => <TabIcon name="calendar" color={String(color)} />,
}}
/>
<Tabs.Screen
name="shopping"
options={{
title: t('nav.shopping'),
tabBarIcon: ({ color }) => <TabIcon name="shopping-cart" color={String(color)} />,
}}
/>
<Tabs.Screen
name="manage"
options={{
title: t('nav.manageMeals'),
tabBarIcon: ({ color }) => <TabIcon name="plus-square" color={String(color)} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t('nav.settings'),
tabBarIcon: ({ color }) => <TabIcon name="cog" color={String(color)} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,945 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
copyYesterdayMeals,
createReminder,
deleteDrink,
deleteMeal,
deleteReminder,
fetchDailySummary,
fetchDrinkCatalog,
fetchFavoriteCatalogItems,
fetchFavoriteRecipes,
fetchGoals,
fetchHistory,
fetchMealSuggestions,
fetchRecentMeals,
fetchReminders,
logDrink,
logMeal,
previewMeal,
saveGoals,
updateReminder,
} from '@/src/api/diet.api';
import { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { fetchRecipes } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { DateNavigator } from '@/src/components/DateNavigator';
import { ErrorView } from '@/src/components/ErrorView';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { SimpleBarChart } from '@/src/components/SimpleBarChart';
import { useAppearance } from '@/src/context/AppearanceContext';
import {
CONSUMPTION_CATEGORIES,
DietReminderType,
REMINDER_DAYS_MASK_ALL,
todayIso,
type DietSuggestion,
type LogMealInput,
type MealPreview,
type UserDailyGoals,
} from '@/src/types/diet';
import { extractApiError } from '@/src/utils/apiError';
import { historyRange } from '@/src/utils/date';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type MealLogSortColumn = 'name' | 'calories';
type DrinkLogSortColumn = 'name' | 'volume';
type ReminderSortColumn = 'title' | 'time';
type Tab = 'today' | 'goals' | 'reminders' | 'history';
function mealMacroLine(m: {
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}): string {
if (m.calories == null) return '';
return `${m.calories} kcal · P ${Number(m.proteinG ?? 0).toFixed(1)}g · F ${Number(m.fatG ?? 0).toFixed(1)}g · C ${Number(m.carbsG ?? 0).toFixed(1)}g`;
}
function ProgressCard({
label,
consumed,
goal,
remaining,
unit,
remainingLabel,
}: {
label: string;
consumed: number;
goal?: number | null;
remaining?: number | null;
unit: string;
remainingLabel: string;
}) {
const { colors } = useAppearance();
const pct = goal && goal > 0 ? Math.min((consumed / goal) * 100, 100) : 0;
return (
<View style={[styles.progressCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.progressLabel, { color: colors.textMuted }]}>{label}</Text>
<Text style={[styles.progressValue, { color: colors.text }]}>
{consumed}
{goal != null ? (
<Text style={{ fontSize: 14, color: colors.textMuted }}>
{' '}
/ {goal}
{unit}
</Text>
) : null}
</Text>
{remaining != null ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{remainingLabel}: {remaining}
{unit}
</Text>
) : null}
{goal != null ? (
<View style={[styles.progressTrack, { backgroundColor: colors.background }]}>
<View style={[styles.progressFill, { width: `${pct}%`, backgroundColor: colors.brand }]} />
</View>
) : null}
</View>
);
}
export default function DietScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>('today');
const [date, setDate] = useState(todayIso());
const [mealCategory, setMealCategory] = useState(2);
const [recipeSearch, setRecipeSearch] = useState('');
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [customMealName, setCustomMealName] = useState('');
const [portions, setPortions] = useState('1');
const [catalogItemId, setCatalogItemId] = useState<number | null>(null);
const [grams, setGrams] = useState('150');
const [mealPreview, setMealPreview] = useState<MealPreview | null>(null);
const [goalsForm, setGoalsForm] = useState<UserDailyGoals>({});
const [reminderTitle, setReminderTitle] = useState('');
const [reminderTime, setReminderTime] = useState('09:00');
const [reminderType, setReminderType] = useState(DietReminderType.Water);
const [editingReminderId, setEditingReminderId] = useState<number | null>(null);
const [mealSort, setMealSort] = useState<{ column: MealLogSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const [drinkSort, setDrinkSort] = useState<{ column: DrinkLogSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const [reminderSort, setReminderSort] = useState<{ column: ReminderSortColumn; direction: SortDirection }>({
column: 'time',
direction: 'asc',
});
const summaryQuery = useQuery({
queryKey: ['diet', 'summary', date],
queryFn: () => fetchDailySummary(date),
});
const suggestionsQuery = useQuery({
queryKey: ['diet', 'suggestions', date],
queryFn: () => fetchMealSuggestions(date),
enabled: tab === 'today' && !!summaryQuery.data,
});
const catalogQuery = useQuery({
queryKey: ['diet', 'catalog'],
queryFn: fetchDrinkCatalog,
});
const ingredientItemsQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'diet', recipeSearch],
queryFn: () => fetchRecipes({ search: recipeSearch }),
enabled: recipeSearch.trim().length >= 2,
});
const goalsQuery = useQuery({
queryKey: ['diet', 'goals'],
queryFn: fetchGoals,
enabled: tab === 'goals',
});
const remindersQuery = useQuery({
queryKey: ['diet', 'reminders'],
queryFn: fetchReminders,
enabled: tab === 'reminders',
});
const recentsQuery = useQuery({
queryKey: ['diet', 'recents'],
queryFn: () => fetchRecentMeals(8),
enabled: tab === 'today',
});
const favoriteRecipesQuery = useQuery({
queryKey: ['diet', 'favorites', 'recipes'],
queryFn: fetchFavoriteRecipes,
enabled: tab === 'today',
});
const favoriteCatalogQuery = useQuery({
queryKey: ['diet', 'favorites', 'catalog'],
queryFn: fetchFavoriteCatalogItems,
enabled: tab === 'today',
});
const historyRangeValues = historyRange(14);
const historyQuery = useQuery({
queryKey: ['diet', 'history', historyRangeValues.from, historyRangeValues.to],
queryFn: () => fetchHistory(historyRangeValues.from, historyRangeValues.to),
enabled: tab === 'history',
});
useEffect(() => {
if (goalsQuery.data) setGoalsForm(goalsQuery.data);
}, [goalsQuery.data]);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ['diet'] });
const resetMealForm = () => {
setRecipeSearch('');
setSelectedRecipeId(null);
setCustomMealName('');
setCatalogItemId(null);
setPortions('1');
setGrams('150');
setMealPreview(null);
};
const buildMealPayload = useCallback((): LogMealInput | null => {
if (selectedRecipeId) {
return {
recipeId: selectedRecipeId,
mealCategory,
logDate: date,
portions: Number(portions) || 1,
};
}
const gramsNum = Number(grams);
if (catalogItemId && gramsNum > 0) {
return { catalogItemId, grams: gramsNum, mealCategory, logDate: date };
}
if (customMealName.trim()) {
return { name: customMealName.trim(), mealCategory, logDate: date };
}
return null;
}, [catalogItemId, customMealName, date, grams, mealCategory, portions, selectedRecipeId]);
useEffect(() => {
const payload = buildMealPayload();
if (!payload) {
setMealPreview(null);
return;
}
const timer = setTimeout(() => {
void previewMeal(payload, date)
.then(setMealPreview)
.catch(() => setMealPreview(null));
}, 250);
return () => clearTimeout(timer);
}, [buildMealPayload, date]);
const logMealMutation = useMutation({
mutationFn: logMeal,
onSuccess: () => {
resetMealForm();
invalidate();
},
});
const logDrinkMutation = useMutation({ mutationFn: logDrink, onSuccess: invalidate });
const saveGoalsMutation = useMutation({ mutationFn: saveGoals, onSuccess: invalidate });
const createReminderMutation = useMutation({
mutationFn: createReminder,
onSuccess: () => {
setReminderTitle('');
setEditingReminderId(null);
void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] });
},
});
const updateReminderMutation = useMutation({
mutationFn: ({ id, input }: { id: number; input: Parameters<typeof updateReminder>[1] }) => updateReminder(id, input),
onSuccess: () => {
setReminderTitle('');
setEditingReminderId(null);
void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] });
},
});
const copyYesterdayMutation = useMutation({
mutationFn: () => copyYesterdayMeals(date),
onSuccess: () => invalidate(),
});
const deleteMealMutation = useMutation({ mutationFn: deleteMeal, onSuccess: invalidate });
const deleteDrinkMutation = useMutation({ mutationFn: deleteDrink, onSuccess: invalidate });
const deleteReminderMutation = useMutation({
mutationFn: deleteReminder,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['diet', 'reminders'] }),
});
const tabs: { id: Tab; label: string }[] = [
{ id: 'today', label: t('diet.tabToday') },
{ id: 'history', label: t('diet.tabHistory') },
{ id: 'goals', label: t('diet.tabGoals') },
{ id: 'reminders', label: t('diet.tabReminders') },
];
const summary = summaryQuery.data;
const suggestions = suggestionsQuery.data ?? [];
const ingredientItems = ingredientItemsQuery.data ?? [];
const suggestionName = (s: DietSuggestion) =>
i18n.language.startsWith('pl') && s.namePl ? s.namePl : s.name;
const canLogMeal = buildMealPayload() != null;
const sortedMeals = useMemo(() => {
if (!summary) return [];
const { column, direction } = mealSort;
return sortBy(summary.meals, direction, (meal) => (column === 'name' ? meal.name : meal.calories));
}, [summary, mealSort]);
const sortedDrinks = useMemo(() => {
if (!summary) return [];
const { column, direction } = drinkSort;
return sortBy(summary.drinks, direction, (drink) =>
column === 'name' ? drink.name : drink.volumeMl,
);
}, [summary, drinkSort]);
const sortedReminders = useMemo(() => {
const { column, direction } = reminderSort;
return sortBy(remindersQuery.data ?? [], direction, (reminder) =>
column === 'title' ? reminder.title : reminder.timeOfDay,
);
}, [remindersQuery.data, reminderSort]);
return (
<Screen title={t('diet.title')} subtitle={t('diet.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<DateNavigator date={date} onChange={setDate} />
<View style={styles.tabs}>
{tabs.map(({ id, label }) => (
<Pressable
key={id}
onPress={() => setTab(id)}
style={[
styles.tab,
{
backgroundColor: tab === id ? colors.brand : colors.surface,
borderColor: tab === id ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: tab === id ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>
{label}
</Text>
</Pressable>
))}
</View>
{tab === 'today' && (
<>
{summaryQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : summaryQuery.isError ? (
<ErrorView
message={extractApiError(summaryQuery.error, t('errors.loadDietFailed'))}
onRetry={() => summaryQuery.refetch()}
/>
) : summary ? (
<>
<ProgressCard
label={t('diet.calories')}
consumed={summary.calories.consumed}
goal={summary.calories.goal}
remaining={summary.calories.remaining}
unit=" kcal"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('diet.water')}
consumed={summary.waterMl.consumed}
goal={summary.waterMl.goal}
remaining={summary.waterMl.remaining}
unit="ml"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.protein')}
consumed={Number(summary.proteinG.consumed)}
goal={summary.proteinG.goal != null ? Number(summary.proteinG.goal) : null}
remaining={summary.proteinG.remaining != null ? Number(summary.proteinG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.fat')}
consumed={Number(summary.fatG.consumed)}
goal={summary.fatG.goal != null ? Number(summary.fatG.goal) : null}
remaining={summary.fatG.remaining != null ? Number(summary.fatG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<ProgressCard
label={t('recipes.carbs')}
consumed={Number(summary.carbsG.consumed)}
goal={summary.carbsG.goal != null ? Number(summary.carbsG.goal) : null}
remaining={summary.carbsG.remaining != null ? Number(summary.carbsG.remaining) : null}
unit="g"
remainingLabel={t('diet.remaining')}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.quickLog')}</Text>
<Button
title={t('diet.copyYesterday')}
variant="secondary"
loading={copyYesterdayMutation.isPending}
onPress={() => copyYesterdayMutation.mutate()}
/>
{(recentsQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.recents')}</Text>
<View style={styles.chips}>
{recentsQuery.data!.map((meal, idx) => (
<Pressable
key={`${meal.name}-${idx}`}
disabled={logMealMutation.isPending}
onPress={() => {
const payload: LogMealInput = { mealCategory: meal.mealCategory, logDate: date, portions: Number(meal.portions) || 1 };
if (meal.recipeId) payload.recipeId = meal.recipeId;
else if (meal.catalogItemId && meal.grams) {
payload.catalogItemId = meal.catalogItemId;
payload.grams = Number(meal.grams);
} else payload.name = meal.name;
logMealMutation.mutate(payload);
}}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>{meal.name}</Text>
</Pressable>
))}
</View>
</>
) : null}
{(favoriteRecipesQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.favoriteRecipes')}</Text>
<View style={styles.chips}>
{favoriteRecipesQuery.data!.map((fav) => (
<Pressable
key={fav.recipeId}
disabled={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({ recipeId: fav.recipeId, mealCategory, logDate: date, portions: 1 })
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>{fav.name}</Text>
</Pressable>
))}
</View>
</>
) : null}
{(favoriteCatalogQuery.data?.length ?? 0) > 0 ? (
<>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.favoriteCatalog')}</Text>
<View style={styles.chips}>
{favoriteCatalogQuery.data!.map((fav) => (
<Pressable
key={fav.catalogItemId}
disabled={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({
catalogItemId: fav.catalogItemId,
grams: 150,
mealCategory,
logDate: date,
})
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontSize: 12 }}>
{i18n.language.startsWith('pl') && fav.namePl ? fav.namePl : fav.name}
</Text>
</Pressable>
))}
</View>
</>
) : null}
{suggestions.length > 0 ? (
<>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.suggestionsTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('diet.suggestionsHint')}</Text>
{suggestions.map((s) => (
<View
key={s.catalogItemId}
style={[styles.suggestionRow, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{suggestionName(s)}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{s.suggestedGrams}g · {s.calories ?? '?'} kcal · P {Number(s.proteinG ?? 0).toFixed(1)}g ·{' '}
{t(`diet.suggestionReasons.${s.reasonKey}`)}
</Text>
</View>
<Button
title={`+ ${t('diet.logSuggestion')}`}
variant="secondary"
loading={logMealMutation.isPending}
onPress={() =>
logMealMutation.mutate({
catalogItemId: s.catalogItemId,
grams: s.suggestedGrams,
mealCategory,
logDate: date,
})
}
/>
</View>
))}
</>
) : null}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.logDrink')}</Text>
<View style={styles.chips}>
{catalogQuery.data?.map((item) => (
<Pressable
key={item.id}
disabled={logDrinkMutation.isPending}
onPress={() =>
logDrinkMutation.mutate({
drinkCatalogId: item.id,
volumeMl: item.defaultVolumeMl,
logDate: date,
})
}
style={[styles.chip, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}>
{item.iconEmoji ?? '🥤'} {item.name} +{item.defaultVolumeMl}ml
</Text>
</Pressable>
))}
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.logMeal')}</Text>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{CONSUMPTION_CATEGORIES.map((c) => (
<Pressable
key={c.value}
onPress={() => setMealCategory(c.value)}
style={[
styles.chip,
{
backgroundColor: mealCategory === c.value ? colors.brand : colors.surface,
borderColor: mealCategory === c.value ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: mealCategory === c.value ? '#fff' : colors.text, fontSize: 12 }}>
{t(c.labelKey)}
</Text>
</Pressable>
))}
</View>
<Input
placeholder={t('recipes.searchRecipes')}
value={recipeSearch}
onChangeText={(v) => {
setRecipeSearch(v);
setSelectedRecipeId(null);
setCatalogItemId(null);
}}
/>
{recipesQuery.data?.map((r) => (
<Pressable
key={r.id}
onPress={() => {
setSelectedRecipeId(r.id);
setCustomMealName('');
setCatalogItemId(null);
}}
style={[
styles.recipePick,
{
backgroundColor: selectedRecipeId === r.id ? colors.brand : colors.surface,
borderColor: colors.border,
},
]}
>
<Text style={{ color: selectedRecipeId === r.id ? '#fff' : colors.text }}>{r.name}</Text>
</Pressable>
))}
<Input
label={t('diet.portions')}
keyboardType="decimal-pad"
value={portions}
onChangeText={setPortions}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('diet.fromCatalog')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable
onPress={() => setCatalogItemId(null)}
style={[
styles.chip,
{
backgroundColor: catalogItemId == null ? colors.brand : colors.surface,
borderColor: catalogItemId == null ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>
{t('diet.noCatalogItem')}
</Text>
</Pressable>
{ingredientItems.map((item) => (
<Pressable
key={item.id}
onPress={() => {
setCatalogItemId(item.id);
setSelectedRecipeId(null);
setCustomMealName('');
}}
style={[
styles.chip,
{
backgroundColor: catalogItemId === item.id ? colors.brand : colors.surface,
borderColor: catalogItemId === item.id ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>
{ingredientDisplayName(item, i18n.language)}
</Text>
</Pressable>
))}
</ScrollView>
{catalogItemId ? (
<Input
label={t('diet.grams')}
keyboardType="numeric"
value={grams}
onChangeText={setGrams}
/>
) : null}
<Input
label={t('diet.orCustomMeal')}
value={customMealName}
onChangeText={(v) => {
setCustomMealName(v);
setSelectedRecipeId(null);
setCatalogItemId(null);
}}
/>
{mealPreview ? (
<View style={[styles.previewBox, { borderColor: colors.brand, backgroundColor: colors.surface }]}>
<Text style={{ color: colors.text, fontWeight: '700' }}>
{t('diet.mealPreview')}: {mealPreview.meal.name}
</Text>
<Text style={{ color: colors.text, marginTop: 6, fontSize: 13 }}>
{mealPreview.meal.calories ?? '?'} kcal · P {Number(mealPreview.meal.proteinG ?? 0).toFixed(1)}g · F{' '}
{Number(mealPreview.meal.fatG ?? 0).toFixed(1)}g · C {Number(mealPreview.meal.carbsG ?? 0).toFixed(1)}g
</Text>
<Text style={{ color: colors.textMuted, marginTop: 6, fontSize: 13 }}>
{t('diet.remainingAfter')}: {mealPreview.remainingAfter.calories ?? '?'} kcal, P{' '}
{mealPreview.remainingAfter.proteinG ?? '?'}g, F {mealPreview.remainingAfter.fatG ?? '?'}g, C{' '}
{mealPreview.remainingAfter.carbsG ?? '?'}g
</Text>
</View>
) : null}
<Button
title={t('diet.markEaten')}
loading={logMealMutation.isPending}
disabled={!canLogMeal}
onPress={() => {
const payload = buildMealPayload();
if (payload) logMealMutation.mutate(payload);
}}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('diet.mealsToday')}</Text>
{summary.meals.length > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(mealSort, 'name')}`}
active={mealSort.column === 'name'}
onPress={() => setMealSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('recipes.calories')}${sortIndicator(mealSort, 'calories')}`}
active={mealSort.column === 'calories'}
onPress={() => setMealSort((current) => nextSort(current, 'calories'))}
/>
</View>
) : null}
{summary.meals.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.nothingLogged')}</Text>
) : (
sortedMeals.map((m) => (
<View key={m.id} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{m.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{mealMacroLine(m) || t('diet.noMacros')}
</Text>
</View>
<Button title="✕" variant="ghost" onPress={() => deleteMealMutation.mutate(m.id)} />
</View>
))
)}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.drinksToday')}</Text>
{summary.drinks.length > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(drinkSort, 'name')}`}
active={drinkSort.column === 'name'}
onPress={() => setDrinkSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('diet.water')}${sortIndicator(drinkSort, 'volume')}`}
active={drinkSort.column === 'volume'}
onPress={() => setDrinkSort((current) => nextSort(current, 'volume'))}
/>
</View>
) : null}
{summary.drinks.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.nothingLogged')}</Text>
) : (
sortedDrinks.map((d) => (
<View key={d.id} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{d.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{d.volumeMl} ml</Text>
</View>
<Button title="✕" variant="ghost" onPress={() => deleteDrinkMutation.mutate(d.id)} />
</View>
))
)}
</>
) : null}
</>
)}
{tab === 'goals' && (
<>
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>{t('diet.goalsHint')}</Text>
{(
[
['calorieGoal', 'diet.calorieGoal'],
['proteinGoalG', 'manage.proteinG'],
['fatGoalG', 'manage.fatG'],
['carbsGoalG', 'manage.carbsG'],
['waterGoalMl', 'diet.waterGoal'],
] as const
).map(([key, labelKey]) => (
<Input
key={key}
label={t(labelKey)}
keyboardType="numeric"
value={goalsForm[key]?.toString() ?? ''}
onChangeText={(v) =>
setGoalsForm((prev) => ({
...prev,
[key]: v === '' ? null : Number(v),
}))
}
/>
))}
<Button
title={t('diet.saveGoals')}
loading={saveGoalsMutation.isPending}
onPress={() => saveGoalsMutation.mutate(goalsForm)}
/>
</>
)}
{tab === 'history' && (
<>
{historyQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : historyQuery.isError ? (
<ErrorView message={t('errors.loadHistoryFailed')} onRetry={() => historyQuery.refetch()} />
) : (
<>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('diet.historyHint')}</Text>
<SimpleBarChart
unit=" kcal"
data={(historyQuery.data ?? []).map((day) => ({
label: day.date.slice(5),
value: day.calories,
}))}
/>
{(historyQuery.data ?? []).map((day) => (
<View key={day.date} style={[styles.logRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{day.date}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{day.calories} kcal · P {Number(day.proteinG).toFixed(0)}g · {day.mealCount} {t('diet.mealsShort')}
</Text>
</View>
</View>
))}
</>
)}
</>
)}
{tab === 'reminders' && (
<>
<Input
label={t('diet.reminderTitle')}
value={reminderTitle}
onChangeText={setReminderTitle}
placeholder={t('diet.reminderTitlePlaceholder')}
/>
<Input label={t('diet.reminderTime')} value={reminderTime} onChangeText={setReminderTime} />
<Button
title={editingReminderId ? t('diet.updateReminder') : t('diet.saveReminder')}
loading={createReminderMutation.isPending || updateReminderMutation.isPending}
disabled={!reminderTitle.trim()}
onPress={() => {
const payload = {
reminderType,
title: reminderTitle.trim(),
timeOfDay: reminderTime,
daysOfWeekMask: REMINDER_DAYS_MASK_ALL,
};
if (editingReminderId) {
updateReminderMutation.mutate({ id: editingReminderId, input: payload });
} else {
createReminderMutation.mutate(payload);
}
}}
/>
{editingReminderId ? (
<Button
title={t('common.cancel')}
variant="ghost"
onPress={() => {
setEditingReminderId(null);
setReminderTitle('');
setReminderTime('09:00');
}}
/>
) : null}
<Text style={[styles.section, { color: colors.text }]}>{t('diet.yourReminders')}</Text>
{(remindersQuery.data?.length ?? 0) > 0 ? (
<View style={styles.sortRow}>
<SortChip
label={`${t('diet.reminderTitle')}${sortIndicator(reminderSort, 'title')}`}
active={reminderSort.column === 'title'}
onPress={() => setReminderSort((current) => nextSort(current, 'title'))}
/>
<SortChip
label={`${t('diet.reminderTime')}${sortIndicator(reminderSort, 'time')}`}
active={reminderSort.column === 'time'}
onPress={() => setReminderSort((current) => nextSort(current, 'time'))}
/>
</View>
) : null}
{remindersQuery.data?.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('diet.noReminders')}</Text>
) : (
sortedReminders.map((r) => (
<View key={r.id} style={[styles.logRow, { borderColor: colors.border }]}>
<Pressable
style={{ flex: 1 }}
onPress={() => {
setEditingReminderId(r.id);
setReminderTitle(r.title);
setReminderTime(r.timeOfDay.slice(0, 5));
setReminderType(r.reminderType);
}}
>
<Text style={{ color: colors.text, fontWeight: '600' }}>{r.title}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{r.timeOfDay.slice(0, 5)}</Text>
</Pressable>
<Button title={t('common.edit')} variant="ghost" onPress={() => {
setEditingReminderId(r.id);
setReminderTitle(r.title);
setReminderTime(r.timeOfDay.slice(0, 5));
setReminderType(r.reminderType);
}} />
<Button title="✕" variant="ghost" onPress={() => deleteReminderMutation.mutate(r.id)} />
</View>
))
)}
</>
)}
</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>
);
}
const styles = StyleSheet.create({
scroll: { gap: 12, paddingBottom: 32 },
tabs: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tab: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
section: { fontSize: 17, fontWeight: '700', marginTop: 8 },
label: { fontSize: 14, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 8 },
recipePick: { borderWidth: 1, borderRadius: 10, padding: 10 },
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
padding: 10,
gap: 8,
},
previewBox: { borderWidth: 1, borderRadius: 12, padding: 12 },
sortRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
sortChip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6 },
logRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
padding: 10,
gap: 8,
},
progressCard: { borderWidth: 1, borderRadius: 14, padding: 14, gap: 6 },
progressLabel: { fontSize: 13, fontWeight: '600' },
progressValue: { fontSize: 22, fontWeight: '700' },
progressTrack: { height: 8, borderRadius: 999, overflow: 'hidden', marginTop: 4 },
progressFill: { height: '100%', borderRadius: 999 },
});

View File

@@ -0,0 +1,198 @@
import { router } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchCategories } from '@/src/api/recipes.api';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { MealCategory } from '@/src/types/recipe';
import { categoryLabel } from '@/src/utils/recipe';
const CATEGORY_EMOJI: Record<MealCategory, string> = {
[MealCategory.Breakfast]: '🍳',
[MealCategory.SecondBreakfast]: '🥐',
[MealCategory.Lunch]: '🍲',
[MealCategory.Dinner]: '🌙',
};
export default function DashboardScreen() {
const { t } = useTranslation();
const { user } = useAuth();
const { colors } = useAppearance();
const categoriesQuery = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
});
const welcome = user?.userName
? t('dashboard.welcomeNamed', { name: user.userName })
: t('dashboard.welcome');
return (
<Screen title={welcome} subtitle={t('dashboard.subtitle')}>
{categoriesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : categoriesQuery.isError ? (
<ErrorView
message={t('errors.loadCategoriesFailed')}
onRetry={() => categoriesQuery.refetch()}
/>
) : (
<ScrollView contentContainerStyle={styles.list}>
<Pressable
onPress={() => router.push('/(main)/(tabs)/meals')}
style={({ pressed }) => [
styles.allCard,
{
backgroundColor: colors.brand,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.allTitle}>{t('nav.allMeals')}</Text>
<Text style={styles.allSubtitle}>{t('dashboard.viewRecipes')}</Text>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/ingredients')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>🥬</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.ingredientCatalog')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('ingredientCatalog.subtitle')}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/diet-generator')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}></Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.dietGenerator')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('generators.diet.subtitle')}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => router.push('/(main)/recipe-generator')}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>🤖</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('nav.recipeGenerator')}</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('generators.recipe.subtitle')}
</Text>
</View>
</Pressable>
{(categoriesQuery.data ?? []).map((item) => (
<Pressable
key={item.category}
onPress={() => router.push(`/(main)/category/${item.category}`)}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={styles.emoji}>{CATEGORY_EMOJI[item.category as MealCategory] ?? '🍽️'}</Text>
<View style={styles.cardBody}>
<Text style={[styles.cardTitle, { color: colors.text }]}>
{categoryLabel(item.category, t)}
</Text>
<Text style={[styles.cardCount, { color: colors.textMuted }]}>
{t('common.recipeCount', { count: item.count })}
</Text>
</View>
</Pressable>
))}
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
list: {
gap: 12,
paddingBottom: 24,
},
allCard: {
borderRadius: 16,
padding: 18,
gap: 4,
},
allTitle: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
},
allSubtitle: {
color: 'rgba(255,255,255,0.9)',
fontSize: 14,
},
card: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
emoji: {
fontSize: 28,
},
cardBody: {
flex: 1,
gap: 2,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
},
cardCount: {
fontSize: 14,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,362 @@
import { 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 { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { createRecipe } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ExcelImportPanel } from '@/src/components/ExcelImportPanel';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { MealCategory } from '@/src/types/recipe';
import { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
type ManageTab = 'manual' | 'import';
interface IngredientRow {
name: string;
amountGrams: string;
unit: string;
catalogItemId: number | null;
}
interface StepRow {
description: string;
}
export default function ManageScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [manageTab, setManageTab] = useState<ManageTab>('manual');
const catalogQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const [name, setName] = useState('');
const [category, setCategory] = useState<MealCategory>(MealCategory.Lunch);
const [prepTime, setPrepTime] = useState('');
const [calories, setCalories] = useState('');
const [protein, setProtein] = useState('');
const [fat, setFat] = useState('');
const [carbs, setCarbs] = useState('');
const [ingredients, setIngredients] = useState<IngredientRow[]>([
{ name: '', amountGrams: '', unit: '', catalogItemId: null },
]);
const [steps, setSteps] = useState<StepRow[]>([{ description: '' }]);
const [error, setError] = useState<string | null>(null);
const saveMutation = useMutation({
mutationFn: createRecipe,
onSuccess: (recipe) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] });
queryClient.invalidateQueries({ queryKey: ['categories'] });
Alert.alert(t('manage.title'), t('manage.savedSuccess', { name: recipe.name }));
setName('');
setPrepTime('');
setCalories('');
setProtein('');
setFat('');
setCarbs('');
setIngredients([{ name: '', amountGrams: '', unit: '', catalogItemId: null }]);
setSteps([{ description: '' }]);
setError(null);
},
onError: (err) => {
setError(extractApiError(err, t('errors.saveRecipeFailed')));
},
});
function validate(): string | null {
if (!name.trim()) return t('validation.recipeNameRequired');
const validSteps = steps.filter((s) => s.description.trim());
if (validSteps.length === 0) return t('validation.stepsMin');
return null;
}
function handleSave() {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
const filteredIngredients = ingredients
.filter((i) => i.name.trim())
.map((i, index) => ({
name: i.name.trim(),
amountGrams: parseOptionalNumber(i.amountGrams),
unit: i.unit,
catalogItemId: i.catalogItemId,
sortOrder: index,
}));
const filteredSteps = steps
.filter((s) => s.description.trim())
.map((s, index) => ({
stepNumber: index + 1,
description: s.description.trim(),
}));
saveMutation.mutate({
name: name.trim(),
mealCategory: category,
calories: parseOptionalNumber(calories),
protein: parseOptionalNumber(protein),
fat: parseOptionalNumber(fat),
carbs: parseOptionalNumber(carbs),
prepTimeMinutes: parseOptionalNumber(prepTime),
ingredients: filteredIngredients,
steps: filteredSteps,
});
}
const catalogItems = catalogQuery.data ?? [];
return (
<Screen title={t('manage.title')} subtitle={t('manage.subtitle')}>
<View style={styles.tabs}>
{(['manual', 'import'] as ManageTab[]).map((tab) => (
<Pressable
key={tab}
onPress={() => setManageTab(tab)}
style={[styles.tab, { backgroundColor: manageTab === tab ? colors.brand : colors.surface, borderColor: manageTab === tab ? colors.brand : colors.border }]}
>
<Text style={{ color: manageTab === tab ? '#fff' : colors.text, fontWeight: '600', fontSize: 13 }}>
{t(tab === 'manual' ? 'manage.tabManual' : 'manage.tabImport')}
</Text>
</Pressable>
))}
</View>
{manageTab === 'import' ? (
<ExcelImportPanel onImported={() => queryClient.invalidateQueries({ queryKey: ['recipes'] })} />
) : (
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={[styles.section, { color: colors.text }]}>{t('manage.basicInfo')}</Text>
<Input
label={t('manage.recipeName')}
placeholder={t('manage.recipeNamePlaceholder')}
value={name}
onChangeText={setName}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{ALL_CATEGORIES.map((cat) => {
const selected = category === cat;
return (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
style={[
styles.chip,
{
backgroundColor: selected ? colors.brand : colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: selected ? '#fff' : colors.text, fontSize: 13 }}>
{categoryLabel(cat, t)}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.row}>
<View style={styles.half}>
<Input
label={t('manage.prepTimeMin')}
keyboardType="numeric"
value={prepTime}
onChangeText={setPrepTime}
/>
</View>
<View style={styles.half}>
<Input
label={t('manage.caloriesKcal')}
keyboardType="numeric"
value={calories}
onChangeText={setCalories}
/>
</View>
</View>
<View style={styles.row}>
<View style={styles.third}>
<Input label={t('manage.proteinG')} keyboardType="numeric" value={protein} onChangeText={setProtein} />
</View>
<View style={styles.third}>
<Input label={t('manage.fatG')} keyboardType="numeric" value={fat} onChangeText={setFat} />
</View>
<View style={styles.third}>
<Input label={t('manage.carbsG')} keyboardType="numeric" value={carbs} onChangeText={setCarbs} />
</View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{ingredients.map((ing, index) => (
<View key={index} style={styles.block}>
<Input
label={t('manage.ingredientName')}
value={ing.name}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], name: value };
setIngredients(next);
}}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('manage.catalogItem')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable
onPress={() => {
const next = [...ingredients];
next[index] = { ...next[index], catalogItemId: null };
setIngredients(next);
}}
style={[styles.chip, { backgroundColor: ing.catalogItemId == null ? colors.brand : colors.surface, borderColor: ing.catalogItemId == null ? colors.brand : colors.border }]}
>
<Text style={{ color: ing.catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>{t('diet.noCatalogItem')}</Text>
</Pressable>
{catalogItems.map((item) => (
<Pressable
key={item.id}
onPress={() => {
const next = [...ingredients];
next[index] = { ...next[index], catalogItemId: item.id, name: ingredientDisplayName(item, i18n.language) };
setIngredients(next);
}}
style={[styles.chip, { backgroundColor: ing.catalogItemId === item.id ? colors.brand : colors.surface, borderColor: ing.catalogItemId === item.id ? colors.brand : colors.border }]}
>
<Text style={{ color: ing.catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>{ingredientDisplayName(item, i18n.language)}</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.row}>
<View style={styles.half}>
<Input
label={t('manage.ingredientGrams')}
keyboardType="numeric"
value={ing.amountGrams}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], amountGrams: value };
setIngredients(next);
}}
/>
</View>
<View style={styles.half}>
<Input
label={t('manage.ingredientUnit')}
value={ing.unit}
onChangeText={(value) => {
const next = [...ingredients];
next[index] = { ...next[index], unit: value };
setIngredients(next);
}}
/>
</View>
</View>
{ingredients.length > 1 ? (
<Button
title={t('manage.removeIngredient')}
variant="ghost"
onPress={() => setIngredients(ingredients.filter((_, i) => i !== index))}
/>
) : null}
</View>
))}
<Button
title={t('common.add')}
variant="secondary"
onPress={() => setIngredients([...ingredients, { name: '', amountGrams: '', unit: '', catalogItemId: null }])}
/>
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
{steps.map((step, index) => (
<View key={index} style={styles.block}>
<Input
label={`${index + 1}.`}
placeholder={t('manage.stepPlaceholder')}
value={step.description}
onChangeText={(value) => {
const next = [...steps];
next[index] = { description: value };
setSteps(next);
}}
multiline
/>
{steps.length > 1 ? (
<Button
title={t('manage.removeStep')}
variant="ghost"
onPress={() => setSteps(steps.filter((_, i) => i !== index))}
/>
) : null}
</View>
))}
<Button title={t('manage.addStep')} variant="secondary" onPress={() => setSteps([...steps, { description: '' }])} />
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Button
title={saveMutation.isPending ? t('manage.saving') : t('manage.saveRecipe')}
loading={saveMutation.isPending}
onPress={handleSave}
/>
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
scroll: {
gap: 12,
paddingBottom: 32,
},
tabs: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tab: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 4,
},
label: {
fontSize: 14,
fontWeight: '600',
},
chips: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
},
row: {
flexDirection: 'row',
gap: 10,
},
half: {
flex: 1,
},
third: {
flex: 1,
},
block: {
gap: 8,
},
error: {
fontSize: 14,
},
});

View File

@@ -0,0 +1,163 @@
import { router } from 'expo-router';
import { useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipes } from '@/src/api/recipes.api';
import { EmptyState, RecipeCard } from '@/src/components/RecipeCard';
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 { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
{ id: 'name', labelKey: 'manage.recipeName' },
{ id: 'calories', labelKey: 'recipes.calories' },
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
];
export default function MealsScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [search, setSearch] = useState('');
const [ingredient, setIngredient] = useState('');
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const recipesQuery = useQuery({
queryKey: ['recipes', search, ingredient],
queryFn: () =>
fetchRecipes({
search: search.trim() || undefined,
ingredient: ingredient.trim() || undefined,
}),
});
const emptyMessage = useMemo(() => {
if (ingredient.trim()) return t('recipes.noMatchingIngredient');
return t('recipes.noMatchingFilter');
}, [ingredient, t]);
const sortedRecipes = useMemo(() => {
const list = recipesQuery.data ?? [];
const { column, direction } = recipeSort;
return sortBy(list, direction, (recipe) => {
switch (column) {
case 'name':
return recipe.name;
case 'calories':
return recipe.calories;
case 'prepTime':
return recipe.prepTimeMinutes;
}
});
}, [recipesQuery.data, recipeSort]);
return (
<Screen title={t('recipes.allMealsTitle')} subtitle={t('recipes.allMealsSubtitle')}>
<View style={styles.filters}>
<Input
placeholder={t('recipes.searchRecipes')}
value={search}
onChangeText={setSearch}
autoCapitalize="none"
/>
<Input
placeholder={t('recipes.searchByIngredient')}
value={ingredient}
onChangeText={setIngredient}
autoCapitalize="none"
/>
</View>
{(recipesQuery.data?.length ?? 0) > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
<SortChip
key={id}
label={`${t(labelKey)}${sortIndicator(recipeSort, id)}`}
active={recipeSort.column === id}
onPress={() => setRecipeSort((current) => nextSort(current, id))}
/>
))}
</ScrollView>
) : null}
{recipesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : recipesQuery.isError ? (
<ErrorView
message={t('errors.loadRecipesFailed')}
onRetry={() => recipesQuery.refetch()}
/>
) : (
<FlatList
data={sortedRecipes}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<EmptyState title={t('recipes.noMatchingTitle')} description={emptyMessage} />
}
renderItem={({ item }) => (
<RecipeCard
recipe={item}
onPress={() => router.push(`/(main)/recipe/${item.id}`)}
/>
)}
/>
)}
</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>
);
}
const styles = StyleSheet.create({
filters: {
gap: 10,
},
sortRow: {
gap: 8,
paddingBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
list: {
gap: 10,
paddingBottom: 24,
flexGrow: 1,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,348 @@
import { useMemo, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { deleteMealPlanEntry, fetchMealPlanEntries, upsertMealPlanEntry } from '@/src/api/meal-plan.api';
import { fetchGoals } from '@/src/api/diet.api';
import { fetchRecipes } from '@/src/api/recipes.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 { CONSUMPTION_CATEGORIES } from '@/src/types/diet';
import type { MealPlanEntry } from '@/src/types/meal-plan';
import { MealCategory } from '@/src/types/recipe';
import { addDays, formatDisplayDate, weekDays, weekRange } from '@/src/utils/date';
import { categoryLabel } from '@/src/utils/recipe';
import {
computeDayMealPlanning,
estimateEntryCalories,
rankRecipeSuggestions,
} from '@/src/utils/mealPlannerSuggestions';
const PLANNER_CATEGORIES = CONSUMPTION_CATEGORIES.filter((c) => c.value <= MealCategory.Dinner);
export default function PlannerScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
const days = useMemo(() => weekDays(from), [from]);
const [picker, setPicker] = useState<{ date: string; mealCategory: number; entry?: MealPlanEntry } | null>(null);
const [recipeSearch, setRecipeSearch] = useState('');
const [portions, setPortions] = useState('1');
const [dailyTargets, setDailyTargets] = useState<Record<string, number>>({});
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const entriesQuery = useQuery({
queryKey: ['meal-plan', from, to],
queryFn: () => fetchMealPlanEntries(from, to),
});
const goalsQuery = useQuery({
queryKey: ['diet-goals'],
queryFn: fetchGoals,
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'planner-search', recipeSearch],
queryFn: () => fetchRecipes({ search: recipeSearch }),
enabled: !!picker && recipeSearch.trim().length >= 2,
});
const suggestionsQuery = useQuery({
queryKey: ['recipes', 'planner-suggestions', picker?.mealCategory],
queryFn: () => fetchRecipes({ category: picker!.mealCategory as MealCategory }),
enabled: !!picker,
});
const entries = entriesQuery.data ?? [];
const dailyTarget = picker
? (dailyTargets[picker.date] ?? goalsQuery.data?.calorieGoal ?? 0)
: 0;
const selectedRecipe = useMemo(() => {
if (!selectedRecipeId) return null;
return (
recipesQuery.data?.find((r) => r.id === selectedRecipeId) ??
suggestionsQuery.data?.find((r) => r.id === selectedRecipeId) ??
null
);
}, [selectedRecipeId, recipesQuery.data, suggestionsQuery.data]);
const estimatedMealCalories = estimateEntryCalories(selectedRecipe, Number(portions) || 1);
const dayPlanning = useMemo(() => {
if (!picker) return null;
const pending =
estimatedMealCalories != null && estimatedMealCalories > 0
? { category: picker.mealCategory, calories: estimatedMealCalories }
: null;
return computeDayMealPlanning(picker.date, picker.mealCategory, entries, dailyTarget, pending);
}, [picker, entries, dailyTarget, estimatedMealCalories]);
const suggestions = useMemo(() => {
if (!picker || !dayPlanning || !suggestionsQuery.data) return [];
return rankRecipeSuggestions(suggestionsQuery.data, picker.mealCategory, dayPlanning.slotTarget);
}, [picker, dayPlanning, suggestionsQuery.data]);
const upsertMutation = useMutation({
mutationFn: upsertMealPlanEntry,
onSuccess: () => {
setPicker(null);
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
void queryClient.invalidateQueries({ queryKey: ['meal-plan'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteMealPlanEntry,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['meal-plan'] }),
});
function selectFromSearch(recipeId: number) {
setSelectedRecipeId(recipeId);
setRecipeSearch('');
}
function selectFromSuggestion(recipeId: number) {
setSelectedRecipeId(recipeId);
}
function clearSelection() {
setSelectedRecipeId(null);
}
function entryFor(date: string, mealCategory: number): MealPlanEntry | undefined {
return entriesQuery.data?.find((e) => e.planDate === date && e.mealCategory === mealCategory);
}
return (
<Screen title={t('planner.title')} subtitle={t('planner.subtitle')}>
<View style={styles.header}>
<Pressable
onPress={() => setWeekAnchor(addDays(from, -7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
{formatDisplayDate(from, i18n.language)} {formatDisplayDate(to, i18n.language)}
</Text>
<Pressable
onPress={() => setWeekAnchor(addDays(from, 7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
</View>
<Button title={t('planner.openShopping')} variant="secondary" onPress={() => router.push('/(main)/(tabs)/shopping')} />
{entriesQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : entriesQuery.isError ? (
<ErrorView message={t('errors.loadPlannerFailed')} onRetry={() => entriesQuery.refetch()} />
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View>
<View style={styles.row}>
<View style={styles.corner} />
{days.map((day) => (
<View key={day} style={[styles.dayHead, { borderColor: colors.border }]}>
<Text style={{ color: colors.textMuted, fontSize: 11, fontWeight: '600' }}>
{formatDisplayDate(day, i18n.language)}
</Text>
</View>
))}
</View>
{PLANNER_CATEGORIES.map((cat) => (
<View key={cat.value} style={styles.row}>
<View style={[styles.catLabel, { borderColor: colors.border, backgroundColor: colors.surface }]}>
<Text style={{ color: colors.text, fontSize: 11, fontWeight: '600' }}>{t(cat.labelKey)}</Text>
</View>
{days.map((day) => {
const entry = entryFor(day, cat.value);
return (
<Pressable
key={`${day}-${cat.value}`}
onPress={() => {
setPicker({ date: day, mealCategory: cat.value, entry });
setRecipeSearch('');
setPortions('1');
setSelectedRecipeId(null);
}}
onLongPress={() => {
if (!entry) return;
Alert.alert(t('planner.removeEntry'), entry.recipeName, [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(entry.id) },
]);
}}
style={[styles.cell, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: entry ? colors.text : colors.textMuted, fontSize: 11 }} numberOfLines={2}>
{entry?.recipeName ?? '+'}
</Text>
</Pressable>
);
})}
</View>
))}
</View>
</ScrollView>
)}
<Modal visible={!!picker} transparent animationType="slide" onRequestClose={() => setPicker(null)}>
<View style={styles.modalBackdrop}>
<View style={[styles.modal, { backgroundColor: colors.background }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('planner.assignRecipe')}</Text>
{picker ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{formatDisplayDate(picker.date, i18n.language)} · {categoryLabel(picker.mealCategory as MealCategory, t)}
</Text>
) : null}
<Input
label={t('planner.dailyTarget')}
keyboardType="number-pad"
value={String(dailyTarget || '')}
onChangeText={(text) => {
if (!picker) return;
const value = Number(text);
setDailyTargets((prev) => ({
...prev,
[picker.date]: Number.isFinite(value) ? value : 0,
}));
}}
/>
{dayPlanning && dailyTarget > 0 ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontSize: 13 }}>
{t('planner.daySummary', { logged: dayPlanning.logged, target: dayPlanning.dailyTarget })}
</Text>
<Text style={{ color: colors.textMuted, fontSize: 11, marginTop: 4 }}>
{t('planner.remaining', { count: Math.max(0, dayPlanning.remaining) })}
{dayPlanning.slotTarget > 0
? ` · ${t('planner.slotHint', { count: dayPlanning.slotTarget })}`
: ''}
{dayPlanning.pendingCalories > 0
? ` · ${t('planner.includingSelection', { count: dayPlanning.pendingCalories })}`
: ''}
</Text>
</View>
) : (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('planner.noSuggestions')}</Text>
)}
{suggestions.length > 0 ? (
<View>
<Text style={{ color: colors.text, fontWeight: '600', marginBottom: 6 }}>
{t('planner.suggestionsTitle')}
</Text>
<ScrollView style={{ maxHeight: 160 }}>
{suggestions.map(({ recipe, diff }) => (
<Pressable
key={recipe.id}
onPress={() => selectFromSuggestion(recipe.id)}
style={[
styles.recipePick,
{
borderColor: selectedRecipeId === recipe.id ? colors.brand : colors.border,
borderWidth: selectedRecipeId === recipe.id ? 2 : 1,
backgroundColor: colors.surface,
},
]}
>
<Text style={{ color: colors.text }}>{recipe.name}</Text>
<Text style={{ color: colors.textMuted, fontSize: 11 }}>
{recipe.calories} kcal{diff > 0 ? ` · ±${diff}` : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
<Input placeholder={t('recipes.searchRecipes')} value={recipeSearch} onChangeText={setRecipeSearch} />
{recipeSearch.trim().length >= 2 && !selectedRecipeId ? (
<ScrollView style={{ maxHeight: 200 }}>
{recipesQuery.data?.map((r) => (
<Pressable
key={r.id}
onPress={() => selectFromSearch(r.id)}
style={[styles.recipePick, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}>{r.name}</Text>
{r.calories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{r.calories} kcal</Text>
) : null}
</Pressable>
))}
{recipesQuery.isFetched && !recipesQuery.isLoading && recipesQuery.data?.length === 0 ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('recipes.noMatchingTitle')}</Text>
) : null}
</ScrollView>
) : null}
{selectedRecipe ? (
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.brand }]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Text style={{ color: colors.text, fontSize: 13, flex: 1 }}>
{t('planner.selectedRecipe', { name: selectedRecipe.name })}
{estimatedMealCalories != null
? ` · ${estimatedMealCalories} kcal`
: selectedRecipe.calories != null
? ` · ${selectedRecipe.calories} kcal`
: ''}
</Text>
<Pressable onPress={clearSelection} hitSlop={8}>
<Text style={{ color: colors.textMuted }}></Text>
</Pressable>
</View>
</View>
) : null}
<Input label={t('diet.portions')} keyboardType="decimal-pad" value={portions} onChangeText={setPortions} />
{estimatedMealCalories != null ? (
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{t('planner.estimatedMeal', { count: estimatedMealCalories })}
</Text>
) : null}
<Button
title={t('common.save')}
disabled={!selectedRecipeId || upsertMutation.isPending}
onPress={() => {
if (!picker || !selectedRecipeId) return;
upsertMutation.mutate({
planDate: picker.date,
mealCategory: picker.mealCategory,
recipeId: selectedRecipeId,
portions: Number(portions) || 1,
});
}}
/>
<Button title={t('common.cancel')} variant="ghost" onPress={() => setPicker(null)} />
</View>
</View>
</Modal>
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
row: { flexDirection: 'row' },
corner: { width: 72 },
dayHead: { width: 88, padding: 6, borderWidth: 1, alignItems: 'center' },
catLabel: { width: 72, padding: 6, borderWidth: 1, justifyContent: 'center' },
cell: { width: 88, height: 56, padding: 6, borderWidth: 1, justifyContent: 'center' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
modal: { borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 16, gap: 10, maxHeight: '80%' },
modalTitle: { fontSize: 18, fontWeight: '700' },
summaryBox: { borderWidth: 1, borderRadius: 10, padding: 10 },
recipePick: { borderWidth: 1, borderRadius: 8, padding: 10, marginBottom: 6 },
});

View File

@@ -0,0 +1,154 @@
import { router } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Button } from '@/src/components/Button';
import { Screen } from '@/src/components/Screen';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { APPEARANCE_OPTIONS } from '@/src/theme/appearance';
import { setAppLanguage } from '@/src/i18n';
export default function SettingsScreen() {
const { t, i18n } = useTranslation();
const { user, logout } = useAuth();
const { colors, appearanceId, setAppearance } = useAppearance();
return (
<Screen title={t('settings.title')}>
<ScrollView contentContainerStyle={styles.scroll}>
<Text style={[styles.section, { color: colors.text }]}>{t('settings.account')}</Text>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.label, { color: colors.textMuted }]}>{t('auth.email')}</Text>
<Text style={[styles.value, { color: colors.text }]}>{user?.email ?? '—'}</Text>
{user?.userName ? (
<>
<Text style={[styles.label, { color: colors.textMuted, marginTop: 8 }]}>
{t('auth.displayName')}
</Text>
<Text style={[styles.value, { color: colors.text }]}>{user.userName}</Text>
</>
) : null}
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('settings.preferences')}</Text>
<Text style={[styles.subsection, { color: colors.text }]}>{t('language.label')}</Text>
<View style={styles.row}>
{(['en', 'pl'] as const).map((code) => {
const selected = i18n.language.startsWith(code);
return (
<Pressable
key={code}
onPress={() => setAppLanguage(code)}
style={[
styles.chip,
{
backgroundColor: selected ? colors.brand : colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: selected ? '#fff' : colors.text }}>
{t(`language.${code}`)}
</Text>
</Pressable>
);
})}
</View>
<Text style={[styles.subsection, { color: colors.text }]}>{t('appearance.label')}</Text>
{APPEARANCE_OPTIONS.map((option) => {
const selected = appearanceId === option.id;
return (
<Pressable
key={option.id}
onPress={() => setAppearance(option.id)}
style={[
styles.appearanceRow,
{
backgroundColor: colors.surface,
borderColor: selected ? colors.brand : colors.border,
},
]}
>
<View style={[styles.swatch, { backgroundColor: option.swatch }]} />
<View style={styles.appearanceText}>
<Text style={[styles.appearanceTitle, { color: colors.text }]}>
{t(option.labelKey)}
</Text>
<Text style={{ color: colors.textMuted }}>{t(option.descriptionKey)}</Text>
</View>
</Pressable>
);
})}
<Button
title={t('nav.logout')}
variant="danger"
onPress={async () => {
await logout();
router.replace('/(auth)/login');
}}
/>
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: {
gap: 12,
paddingBottom: 32,
},
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 4,
},
subsection: {
fontSize: 15,
fontWeight: '600',
},
card: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
},
label: {
fontSize: 13,
},
value: {
fontSize: 16,
fontWeight: '500',
},
row: {
flexDirection: 'row',
gap: 10,
},
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
},
appearanceRow: {
borderWidth: 1,
borderRadius: 14,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
swatch: {
width: 28,
height: 28,
borderRadius: 14,
},
appearanceText: {
flex: 1,
gap: 2,
},
appearanceTitle: {
fontWeight: '600',
fontSize: 15,
},
});

View File

@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { useTranslation } from 'react-i18next';
import { fetchShoppingList } from '@/src/api/meal-plan.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { addDays, formatDisplayDate, weekRange } from '@/src/utils/date';
import { buildShoppingListHtml, formatShoppingQuantity } from '@/src/utils/pdfExport';
export default function ShoppingScreen() {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [exporting, setExporting] = useState(false);
const listQuery = useQuery({
queryKey: ['shopping-list', from, to],
queryFn: () => fetchShoppingList(from, to),
});
async function handleExportPdf() {
const items = listQuery.data ?? [];
if (items.length === 0) {
Alert.alert(t('errors.pdfNothingToExport'));
return;
}
setExporting(true);
try {
const html = buildShoppingListHtml(
items,
t('pdf.shoppingList'),
t('pdf.generatedOn', { date: new Date().toLocaleDateString(), count: items.length }),
t('pdf.toTaste'),
);
const { uri } = await Print.printToFileAsync({ html });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { UTI: '.pdf', mimeType: 'application/pdf' });
}
} catch {
Alert.alert(t('errors.pdfGenerateFailed'));
} finally {
setExporting(false);
}
}
return (
<Screen title={t('shopping.title')} subtitle={t('shopping.subtitle')}>
<View style={styles.header}>
<Pressable
onPress={() => setWeekAnchor(addDays(from, -7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
{formatDisplayDate(from, i18n.language)} {formatDisplayDate(to, i18n.language)}
</Text>
<Pressable
onPress={() => setWeekAnchor(addDays(from, 7))}
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text }}></Text>
</Pressable>
</View>
<Button
title={exporting ? t('pdf.generating') : t('pdf.generate')}
variant="secondary"
loading={exporting}
onPress={() => void handleExportPdf()}
/>
{listQuery.isLoading ? (
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
) : listQuery.isError ? (
<ErrorView message={t('errors.loadShoppingFailed')} onRetry={() => listQuery.refetch()} />
) : (listQuery.data?.length ?? 0) === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('shopping.empty')}</Text>
) : (
<ScrollView contentContainerStyle={styles.list}>
{listQuery.data!.map((item) => {
const key = item.name;
const isChecked = checked[key] ?? false;
return (
<Pressable
key={key}
onPress={() => setChecked((prev) => ({ ...prev, [key]: !isChecked }))}
style={[styles.row, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<View style={[styles.checkbox, { borderColor: colors.brand, backgroundColor: isChecked ? colors.brand : 'transparent' }]}>
{isChecked ? <Text style={{ color: '#fff', fontSize: 12 }}></Text> : null}
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: isChecked ? colors.textMuted : colors.text, textDecorationLine: isChecked ? 'line-through' : 'none', fontWeight: '600' }}>
{item.name}
</Text>
{item.breakdown.length > 0 ? (
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{item.breakdown.join(' · ')}</Text>
) : null}
</View>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>
{formatShoppingQuantity(item, t('pdf.toTaste'))}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
list: { gap: 8, paddingBottom: 24 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderRadius: 12, padding: 12 },
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
});

View File

@@ -0,0 +1,38 @@
import { Redirect, Stack } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/src/context/AuthContext';
import { useAppearance } from '@/src/context/AppearanceContext';
import { Screen } from '@/src/components/Screen';
export default function MainLayout() {
const { isAuthenticated, isLoading } = useAuth();
const { colors } = useAppearance();
const { t } = useTranslation();
if (isLoading) {
return <Screen loading />;
}
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.brand,
headerTitleStyle: { color: colors.text },
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="recipe/[id]" options={{ title: t('recipes.allMealsTitle') }} />
<Stack.Screen name="recipe/edit/[id]" options={{ title: t('manage.editRecipe') }} />
<Stack.Screen name="ingredients" options={{ title: t('nav.ingredientCatalog') }} />
<Stack.Screen name="diet-generator" options={{ title: t('nav.dietGenerator') }} />
<Stack.Screen name="recipe-generator" options={{ title: t('nav.recipeGenerator') }} />
<Stack.Screen name="category/[category]" />
</Stack>
);
}

View File

@@ -0,0 +1,152 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useMemo, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipes } from '@/src/api/recipes.api';
import { EmptyState, RecipeCard } from '@/src/components/RecipeCard';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { categoryLabel, parseCategoryParam } from '@/src/utils/recipe';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type RecipeSortColumn = 'name' | 'calories' | 'prepTime';
const RECIPE_SORT_COLUMNS: { id: RecipeSortColumn; labelKey: string }[] = [
{ id: 'name', labelKey: 'manage.recipeName' },
{ id: 'calories', labelKey: 'recipes.calories' },
{ id: 'prepTime', labelKey: 'manage.prepTimeMin' },
];
export default function CategoryScreen() {
const { category: categoryParam } = useLocalSearchParams<{ category: string }>();
const category = parseCategoryParam(categoryParam ?? '');
const { t } = useTranslation();
const { colors } = useAppearance();
const [recipeSort, setRecipeSort] = useState<{ column: RecipeSortColumn; direction: SortDirection }>({
column: 'name',
direction: 'asc',
});
const recipesQuery = useQuery({
queryKey: ['recipes', 'category', category],
queryFn: () => fetchRecipes({ category: category! }),
enabled: category != null,
});
const title = category != null ? categoryLabel(category, t) : t('categories.unknown');
const sortedRecipes = useMemo(() => {
const list = recipesQuery.data ?? [];
const { column, direction } = recipeSort;
return sortBy(list, direction, (recipe) => {
switch (column) {
case 'name':
return recipe.name;
case 'calories':
return recipe.calories;
case 'prepTime':
return recipe.prepTimeMinutes;
}
});
}, [recipesQuery.data, recipeSort]);
if (category == null) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipesFailed')} />
</Screen>
);
}
return (
<>
<Stack.Screen options={{ title, headerShown: true }} />
<Screen subtitle={t('common.recipeCount', { count: recipesQuery.data?.length ?? 0 })}>
{(recipesQuery.data?.length ?? 0) > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.sortRow}>
{RECIPE_SORT_COLUMNS.map(({ id, labelKey }) => (
<SortChip
key={id}
label={`${t(labelKey)}${sortIndicator(recipeSort, id)}`}
active={recipeSort.column === id}
onPress={() => setRecipeSort((current) => nextSort(current, id))}
/>
))}
</ScrollView>
) : null}
{recipesQuery.isLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : recipesQuery.isError ? (
<ErrorView
message={t('errors.loadRecipesFailed')}
onRetry={() => recipesQuery.refetch()}
/>
) : (
<FlatList
data={sortedRecipes}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<EmptyState
title={t('recipes.emptyCategoryTitle', { category: title })}
description={t('recipes.emptyCategoryDescription')}
/>
}
renderItem={({ item }) => (
<RecipeCard
recipe={item}
onPress={() => router.push(`/(main)/recipe/${item.id}`)}
/>
)}
/>
)}
</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>
);
}
const styles = StyleSheet.create({
sortRow: {
gap: 8,
paddingBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
list: {
gap: 10,
paddingBottom: 24,
flexGrow: 1,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
});

View File

@@ -0,0 +1,479 @@
import { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import {
acceptDietMacros,
commitDietPlan,
createDietSession,
fetchDietProfile,
generateDietPlan,
proposeDietMacros,
regenerateDietMeal,
saveDietProfile,
toggleDietMealLock,
} from '@/src/api/diet-generator.api';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import {
DEFAULT_DIET_PROFILE,
type DietGeneratorDraftMeal,
type DietGeneratorSession,
type DietUserProfile,
} from '@/src/types/generator';
import { MealCategory } from '@/src/types/recipe';
import { ApiError, extractApiError } from '@/src/utils/apiError';
import { categoryLabel } from '@/src/utils/recipe';
type Step = 'profile' | 'macros' | 'plan' | 'done';
function formatDraftMealMacros(meal: DietGeneratorDraftMeal): string {
const parts = [`${meal.calories ?? '?'} kcal`];
if (meal.proteinG != null) parts.push(`P ${meal.proteinG}g`);
if (meal.fatG != null) parts.push(`F ${meal.fatG}g`);
if (meal.carbsG != null) parts.push(`C ${meal.carbsG}g`);
return parts.join(' · ');
}
export default function DietGeneratorScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [step, setStep] = useState<Step>('profile');
const [profile, setProfile] = useState<DietUserProfile>({ ...DEFAULT_DIET_PROFILE });
const [sessionId, setSessionId] = useState<number | null>(null);
const [session, setSession] = useState<DietGeneratorSession | null>(null);
const [macros, setMacros] = useState({
calories: 2000,
proteinG: 150,
fatG: 65,
carbsG: 200,
rationale: '',
});
const [commitResult, setCommitResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [previewMeal, setPreviewMeal] = useState<DietGeneratorDraftMeal | null>(null);
useEffect(() => {
void fetchDietProfile().then((p) => {
if (p) setProfile({ ...DEFAULT_DIET_PROFILE, ...p });
});
}, []);
const mealsByDate = useMemo(() => {
const map = new Map<string, DietGeneratorSession['draftMeals']>();
for (const meal of session?.draftMeals ?? []) {
const list = map.get(meal.planDate) ?? [];
list.push(meal);
map.set(meal.planDate, list);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, meals]) => ({
date,
meals,
summary: session?.daySummaries.find((d) => d.planDate === date),
}));
}, [session]);
async function startSession() {
setError(null);
setLoading(true);
try {
await saveDietProfile(profile);
const created = await createDietSession({ planDayCount: 7, calorieToleranceKcal: 10 });
setSessionId(created.id);
if (created.proposedMacros) applyMacros(created);
const refined = await proposeDietMacros(created.id);
if (refined.proposedMacros) applyMacros(refined);
setStep('macros');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
async function acceptAndGenerate() {
if (!sessionId) return;
setError(null);
setLoading(true);
try {
await acceptDietMacros(sessionId, macros);
const plan = await generateDietPlan(sessionId);
setSession(plan);
setStep('plan');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
async function toggleLock(mealId: number, isLocked: boolean) {
if (!sessionId) return;
try {
const updated = await toggleDietMealLock(sessionId, mealId, isLocked);
setSession(updated);
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
}
}
async function regenMeal(planDate: string, mealCategory: number) {
if (!sessionId) return;
try {
const updated = await regenerateDietMeal(sessionId, planDate, mealCategory);
setSession(updated);
setError(null);
setInfo(null);
} catch (e) {
if (e instanceof ApiError && e.status === 400) {
setInfo(t('generators.diet.noAlternativeMeals'));
setError(null);
} else {
setError(extractApiError(e, t('generators.diet.error')));
setInfo(null);
}
}
}
async function commitPlan() {
if (!sessionId) return;
setLoading(true);
try {
const result = await commitDietPlan(sessionId);
setCommitResult(`${result.planStartDate}${result.planEndDate}`);
setStep('done');
} catch (e) {
setError(extractApiError(e, t('generators.diet.error')));
} finally {
setLoading(false);
}
}
function applyMacros(s: DietGeneratorSession) {
if (!s.proposedMacros) return;
setMacros({
calories: s.proposedMacros.calories,
proteinG: s.proposedMacros.proteinG,
fatG: s.proposedMacros.fatG,
carbsG: s.proposedMacros.carbsG,
rationale: s.proposedMacros.rationale ?? '',
});
}
function reset() {
setStep('profile');
setSessionId(null);
setSession(null);
setCommitResult(null);
setError(null);
setInfo(null);
}
return (
<Screen title={t('generators.diet.title')} subtitle={t('generators.diet.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll}>
{info ? (
<View style={[styles.infoBox, { backgroundColor: colors.brand + '22' }]}>
<Text style={{ color: colors.brand }}>{info}</Text>
</View>
) : null}
{error ? (
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
<Text style={{ color: colors.danger }}>{error}</Text>
</View>
) : null}
{step === 'profile' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.diet.profileTitle')}
</Text>
<Input
label={t('generators.diet.heightCm')}
keyboardType="numeric"
value={String(profile.heightCm)}
onChangeText={(v) => setProfile({ ...profile, heightCm: Number(v) || 0 })}
/>
<Input
label={t('generators.diet.weightKg')}
keyboardType="numeric"
value={String(profile.weightKg)}
onChangeText={(v) => setProfile({ ...profile, weightKg: Number(v) || 0 })}
/>
<Input
label={t('generators.diet.age')}
keyboardType="numeric"
value={String(profile.age)}
onChangeText={(v) => setProfile({ ...profile, age: Number(v) || 0 })}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.sex')}</Text>
<View style={styles.row}>
{(['male', 'female'] as const).map((sex) => (
<Pressable
key={sex}
onPress={() => setProfile({ ...profile, sex })}
style={[
styles.chip,
{
backgroundColor: profile.sex === sex ? colors.brand : colors.surface,
borderColor: profile.sex === sex ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.sex === sex ? '#fff' : colors.text }}>
{t(`generators.diet.${sex === 'male' ? 'male' : 'female'}`)}
</Text>
</Pressable>
))}
</View>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.activity')}</Text>
<View style={styles.wrapRow}>
{(['sedentary', 'light', 'moderate', 'active', 'very_active'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setProfile({ ...profile, activityLevel: level })}
style={[
styles.chip,
{
backgroundColor: profile.activityLevel === level ? colors.brand : colors.surface,
borderColor: profile.activityLevel === level ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.activityLevel === level ? '#fff' : colors.text, fontSize: 12 }}>
{t(`generators.diet.${level === 'very_active' ? 'veryActive' : level}`)}
</Text>
</Pressable>
))}
</View>
<Text style={[styles.label, { color: colors.text }]}>{t('generators.diet.goal')}</Text>
<View style={styles.wrapRow}>
{(['lose_weight', 'maintain', 'gain_muscle', 'recomp'] as const).map((goal) => (
<Pressable
key={goal}
onPress={() => setProfile({ ...profile, goal })}
style={[
styles.chip,
{
backgroundColor: profile.goal === goal ? colors.brand : colors.surface,
borderColor: profile.goal === goal ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: profile.goal === goal ? '#fff' : colors.text, fontSize: 12 }}>
{t(
`generators.diet.${
goal === 'lose_weight'
? 'loseWeight'
: goal === 'gain_muscle'
? 'gainMuscle'
: goal
}`,
)}
</Text>
</Pressable>
))}
</View>
<Input
label={t('generators.diet.allergies')}
multiline
value={profile.allergiesNotes ?? ''}
onChangeText={(v) => setProfile({ ...profile, allergiesNotes: v })}
/>
<Button title={t('generators.diet.continue')} loading={loading} onPress={() => void startSession()} />
</View>
) : null}
{step === 'macros' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.diet.macrosTitle')}
</Text>
{macros.rationale ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>{macros.rationale}</Text>
) : null}
<Input
label={t('manage.caloriesKcal')}
keyboardType="numeric"
value={String(macros.calories)}
onChangeText={(v) => setMacros({ ...macros, calories: Number(v) || 0 })}
/>
<Input
label={t('manage.proteinG')}
keyboardType="numeric"
value={String(macros.proteinG)}
onChangeText={(v) => setMacros({ ...macros, proteinG: Number(v) || 0 })}
/>
<Input
label={t('manage.fatG')}
keyboardType="numeric"
value={String(macros.fatG)}
onChangeText={(v) => setMacros({ ...macros, fatG: Number(v) || 0 })}
/>
<Input
label={t('manage.carbsG')}
keyboardType="numeric"
value={String(macros.carbsG)}
onChangeText={(v) => setMacros({ ...macros, carbsG: Number(v) || 0 })}
/>
<View style={styles.buttonRow}>
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('profile')} />
<Button
title={t('generators.diet.acceptAndGenerate')}
loading={loading}
onPress={() => void acceptAndGenerate()}
/>
</View>
</View>
) : null}
{step === 'plan' && session ? (
<View style={styles.gap}>
<Text style={{ color: colors.textMuted }}>{t('generators.diet.planHint')}</Text>
{mealsByDate.map(({ date, meals, summary }) => (
<View
key={date}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.dayHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{date}</Text>
<Text style={{ color: summary?.withinTolerance ? colors.brand : '#b45309' }}>
{summary?.totalCalories ?? 0} / {summary?.targetCalories ?? 0} kcal
{summary?.withinTolerance ? ' ✓' : ''}
</Text>
</View>
{meals.map((meal) => (
<View key={meal.id} style={[styles.mealRow, { borderColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.textMuted, fontSize: 11, textTransform: 'uppercase' }}>
{categoryLabel(meal.mealCategory as MealCategory, t)}
</Text>
<Text style={{ color: colors.text, fontWeight: '600' }}>{meal.recipeName}</Text>
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
{formatDraftMealMacros(meal)}
</Text>
</View>
<View style={styles.mealActions}>
<Button
title={t('generators.diet.viewRecipe')}
variant="secondary"
onPress={() => setPreviewMeal(meal)}
style={styles.smallBtn}
/>
<Button
title={meal.isLocked ? t('generators.diet.unlock') : t('generators.diet.lock')}
variant="secondary"
onPress={() => void toggleLock(meal.id, !meal.isLocked)}
style={styles.smallBtn}
/>
<Button
title="↻"
variant="secondary"
disabled={meal.isLocked}
onPress={() => void regenMeal(meal.planDate, meal.mealCategory)}
style={styles.smallBtn}
/>
</View>
</View>
))}
</View>
))}
<Button title={t('generators.diet.commit')} loading={loading} onPress={() => void commitPlan()} />
</View>
) : null}
{step === 'done' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.brand }]}>{t('generators.diet.done')}</Text>
{commitResult ? <Text style={{ color: colors.textMuted }}>{commitResult}</Text> : null}
<Button title={t('nav.planner')} onPress={() => router.push('/(main)/(tabs)/planner')} />
<Button title={t('generators.diet.newPlan')} variant="secondary" onPress={reset} />
</View>
) : null}
</ScrollView>
<Modal visible={!!previewMeal} transparent animationType="slide" onRequestClose={() => setPreviewMeal(null)}>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<ScrollView contentContainerStyle={styles.modalScroll}>
{previewMeal ? (
<>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{previewMeal.recipeName}</Text>
{previewMeal.calories != null ? (
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
{previewMeal.calories} kcal · P {previewMeal.proteinG ?? 0}g · F {previewMeal.fatG ?? 0}g · C{' '}
{previewMeal.carbsG ?? 0}g
</Text>
) : null}
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{previewMeal.ingredients.map((ing) => (
<Text key={ing.id} style={{ color: colors.text, marginBottom: 4 }}>
{ing.name}
{ing.amountGrams != null ? `${ing.amountGrams}g` : ''}
{ing.unit ? ` ${ing.unit}` : ''}
</Text>
))}
<Text style={[styles.label, { color: colors.text, marginTop: 12 }]}>
{t('recipes.preparation')}
</Text>
{previewMeal.steps.map((step) => (
<Text key={step.id} style={{ color: colors.text, marginBottom: 4 }}>
{step.stepNumber}. {step.description}
</Text>
))}
</>
) : null}
</ScrollView>
<Button title={t('common.back')} variant="secondary" onPress={() => setPreviewMeal(null)} />
</View>
</View>
</Modal>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: { gap: 16, paddingBottom: 32 },
card: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
row: { flexDirection: 'row', gap: 8 },
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
buttonRow: { gap: 10 },
gap: { gap: 12 },
dayHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 },
mealRow: {
borderTopWidth: 1,
paddingTop: 10,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
mealActions: { gap: 6 },
smallBtn: { minHeight: 36, paddingHorizontal: 10 },
errorBox: { borderRadius: 12, padding: 12 },
infoBox: { borderRadius: 12, padding: 12 },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
padding: 16,
},
modalCard: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
maxHeight: '85%',
gap: 12,
},
modalScroll: { gap: 4, paddingBottom: 8 },
});

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' },
});

View File

@@ -0,0 +1,376 @@
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next';
import {
commitRecipes,
createRecipeSession,
generateRecipeDrafts,
regenerateRecipePart,
removeRecipeDraft,
} from '@/src/api/recipe-generator.api';
import { Button } from '@/src/components/Button';
import { Input } from '@/src/components/Input';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import type { CommitRecipeResult, GeneratedRecipeDraft, RecipeGeneratorConstraints } from '@/src/types/generator';
import { MealCategory } from '@/src/types/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ALL_CATEGORIES, categoryLabel } from '@/src/utils/recipe';
type Step = 'constraints' | 'review' | 'done';
export default function RecipeGeneratorScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
const [step, setStep] = useState<Step>('constraints');
const [sessionId, setSessionId] = useState<number | null>(null);
const [drafts, setDrafts] = useState<GeneratedRecipeDraft[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [savedRecipes, setSavedRecipes] = useState<CommitRecipeResult[]>([]);
const [recipeCount, setRecipeCount] = useState('3');
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [constraints, setConstraints] = useState<RecipeGeneratorConstraints>({
prompt: '',
mealCategory: MealCategory.Breakfast,
targetCalories: null,
calorieToleranceKcal: 10,
maxPrepTimeMinutes: 30,
cuisineStyle: '',
dietStyle: '',
exclusions: '',
});
const calorieTolerance = constraints.calorieToleranceKcal ?? 10;
function isWithinCalorieTarget(calories?: number | null): boolean {
if (!constraints.targetCalories || calories == null) return true;
return Math.abs(calories - constraints.targetCalories) <= calorieTolerance;
}
const allSelected = useMemo(
() => drafts.length > 0 && drafts.every((d) => selectedIds.has(d.draftId)),
[drafts, selectedIds],
);
function syncDrafts(next: GeneratedRecipeDraft[]) {
setDrafts(next);
setSelectedIds(new Set(next.map((d) => d.draftId)));
}
async function runGenerate() {
setError(null);
setPending(true);
const count = Math.min(8, Math.max(1, Number(recipeCount) || 3));
try {
let session;
if (sessionId) {
session = await generateRecipeDrafts(sessionId, count);
} else {
const created = await createRecipeSession(constraints);
setSessionId(created.id);
session = await generateRecipeDrafts(created.id, count);
}
syncDrafts(session.drafts ?? []);
setStep('review');
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function regen(draftId: string, mode: 'ingredients' | 'steps' | 'full') {
if (!sessionId) return;
setPending(true);
setError(null);
try {
const session = await regenerateRecipePart(sessionId, draftId, mode, constraints.prompt);
syncDrafts(session.drafts ?? []);
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function removeDraft(draftId: string) {
if (!sessionId) return;
setPending(true);
setError(null);
try {
const session = await removeRecipeDraft(sessionId, draftId);
const next = session.drafts ?? [];
syncDrafts(next);
if (next.length === 0) setStep('constraints');
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
async function saveSelected() {
if (!sessionId || selectedIds.size === 0) return;
setPending(true);
setError(null);
try {
const result = await commitRecipes(sessionId, [...selectedIds]);
setSavedRecipes(result.saved);
const remaining = drafts.filter((d) => !selectedIds.has(d.draftId));
if (remaining.length === 0) setStep('done');
else syncDrafts(remaining);
} catch (e) {
setError(extractApiError(e, t('generators.recipe.error')));
} finally {
setPending(false);
}
}
function toggleSelected(draftId: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(draftId)) next.delete(draftId);
else next.add(draftId);
return next;
});
}
function toggleAll() {
setSelectedIds(allSelected ? new Set() : new Set(drafts.map((d) => d.draftId)));
}
return (
<Screen title={t('generators.recipe.title')} subtitle={t('generators.recipe.subtitle')}>
<ScrollView contentContainerStyle={styles.scroll}>
{error ? (
<View style={[styles.errorBox, { backgroundColor: colors.danger + '22' }]}>
<Text style={{ color: colors.danger }}>{error}</Text>
</View>
) : null}
{step === 'constraints' ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.recipe.constraintsTitle')}
</Text>
<Input
label={t('generators.recipe.prompt')}
multiline
placeholder={t('generators.recipe.promptPlaceholder')}
value={constraints.prompt}
onChangeText={(v) => setConstraints({ ...constraints, prompt: v })}
/>
<Input
label={t('generators.recipe.targetCalories')}
keyboardType="numeric"
placeholder={t('generators.recipe.targetCaloriesOptional')}
value={constraints.targetCalories != null ? String(constraints.targetCalories) : ''}
onChangeText={(v) =>
setConstraints({
...constraints,
targetCalories: v.trim() ? Number(v) || null : null,
})
}
/>
<Text style={{ color: colors.textMuted, fontSize: 12, marginBottom: 12 }}>
{t('generators.recipe.targetCaloriesHint')}
</Text>
<Input
label={t('generators.recipe.recipeCount')}
keyboardType="numeric"
value={recipeCount}
onChangeText={setRecipeCount}
/>
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.wrapRow}>
{ALL_CATEGORIES.map((cat) => (
<Pressable
key={cat}
onPress={() => setConstraints({ ...constraints, mealCategory: cat })}
style={[
styles.chip,
{
backgroundColor: constraints.mealCategory === cat ? colors.brand : colors.surface,
borderColor: constraints.mealCategory === cat ? colors.brand : colors.border,
},
]}
>
<Text style={{ color: constraints.mealCategory === cat ? '#fff' : colors.text, fontSize: 12 }}>
{categoryLabel(cat, t)}
</Text>
</Pressable>
))}
</View>
<Input
label={t('generators.recipe.maxPrep')}
keyboardType="numeric"
value={String(constraints.maxPrepTimeMinutes ?? '')}
onChangeText={(v) =>
setConstraints({ ...constraints, maxPrepTimeMinutes: Number(v) || undefined })
}
/>
<Input
label={t('generators.recipe.exclusions')}
value={constraints.exclusions ?? ''}
onChangeText={(v) => setConstraints({ ...constraints, exclusions: v })}
/>
<Button
title={t('generators.recipe.generate')}
loading={pending}
disabled={!constraints.prompt.trim()}
onPress={() => void runGenerate()}
/>
</View>
) : null}
{step === 'review' && drafts.length > 0 ? (
<View style={styles.gap}>
<View style={styles.rowBetween}>
<Text style={{ color: colors.textMuted, flex: 1 }}>
{t('generators.recipe.reviewHint', { count: drafts.length })}
</Text>
<Pressable onPress={toggleAll} style={styles.selectAll}>
<Text style={{ color: colors.brand, fontWeight: '600' }}>
{t('generators.recipe.selectAll')}
</Text>
</Pressable>
</View>
{drafts.map((draft) => (
<View
key={draft.draftId}
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
>
<View style={styles.rowBetween}>
<Pressable onPress={() => toggleSelected(draft.draftId)} style={styles.checkRow}>
<View
style={[
styles.checkbox,
{
borderColor: selectedIds.has(draft.draftId) ? colors.brand : colors.border,
backgroundColor: selectedIds.has(draft.draftId) ? colors.brand : 'transparent',
},
]}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{draft.name}</Text>
{draft.calories != null ? (
<Text
style={{
color:
constraints.targetCalories && !isWithinCalorieTarget(draft.calories)
? '#b45309'
: colors.textMuted,
fontSize: 13,
}}
>
{constraints.targetCalories
? t('generators.recipe.caloriesWithTarget', {
actual: draft.calories,
target: constraints.targetCalories,
})
: `${draft.calories} kcal`}
{' · '}
P {draft.protein ?? 0}g · F {draft.fat ?? 0}g · C {draft.carbs ?? 0}g
</Text>
) : null}
</View>
</Pressable>
</View>
{draft.unlinkedIngredientCount > 0 ? (
<Text style={{ color: '#b45309' }}>
{t('generators.recipe.unlinkedWarning', { count: draft.unlinkedIngredientCount })}
</Text>
) : null}
<Text style={[styles.label, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{draft.ingredients.map((ing, i) => (
<Text
key={i}
style={{ color: !ing.isLinked && ing.amountGrams ? '#b45309' : colors.text, fontSize: 14 }}
>
{ing.name} {ing.amountGrams ?? '?'}g {ing.unit ?? ''}
</Text>
))}
<Text style={[styles.label, { color: colors.text, marginTop: 8 }]}>
{t('manage.preparationSteps')}
</Text>
{draft.steps.map((s) => (
<Text key={s.stepNumber} style={{ color: colors.text, fontSize: 14 }}>
{s.stepNumber}. {s.description}
</Text>
))}
<View style={styles.gap}>
<Button
title={t('generators.recipe.regenIngredients')}
variant="secondary"
loading={pending}
onPress={() => void regen(draft.draftId, 'ingredients')}
/>
<Button
title={t('generators.recipe.remove')}
variant="danger"
disabled={pending}
onPress={() => void removeDraft(draft.draftId)}
/>
</View>
</View>
))}
<Button
title={t('generators.recipe.saveSelected', { count: selectedIds.size })}
loading={pending}
disabled={selectedIds.size === 0}
onPress={() => void saveSelected()}
/>
<Button title={t('common.back')} variant="secondary" onPress={() => setStep('constraints')} />
</View>
) : null}
{step === 'done' && savedRecipes.length > 0 ? (
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('generators.recipe.doneMultiple', { count: savedRecipes.length })}
</Text>
{savedRecipes.map((r) => (
<Pressable key={r.recipeId} onPress={() => router.push(`/(main)/recipe/${r.recipeId}`)}>
<Text style={{ color: colors.brand, fontSize: 16, marginBottom: 8 }}>{r.recipeName}</Text>
</Pressable>
))}
<Button
title={t('generators.recipe.generateMore')}
variant="secondary"
onPress={() => {
setStep('constraints');
setDrafts([]);
setSelectedIds(new Set());
setSavedRecipes([]);
setSessionId(null);
}}
/>
</View>
) : null}
</ScrollView>
</Screen>
);
}
const styles = StyleSheet.create({
scroll: { gap: 16, paddingBottom: 32 },
card: { borderWidth: 1, borderRadius: 16, padding: 16, gap: 12 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
gap: { gap: 10 },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
checkRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, flex: 1 },
checkbox: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, marginTop: 2 },
selectAll: { paddingVertical: 4, paddingHorizontal: 8 },
errorBox: { borderRadius: 12, padding: 12 },
});

View File

@@ -0,0 +1,205 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRecipeById } from '@/src/api/recipes.api';
import { Button } from '@/src/components/Button';
import { ErrorView } from '@/src/components/ErrorView';
import { Screen } from '@/src/components/Screen';
import { useAppearance } from '@/src/context/AppearanceContext';
import { categoryLabel } from '@/src/utils/recipe';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '@/src/utils/sort';
type IngredientSortColumn = 'name' | 'amount';
export default function RecipeDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t } = useTranslation();
const { colors } = useAppearance();
const [ingredientSort, setIngredientSort] = useState<{
column: IngredientSortColumn;
direction: SortDirection;
}>({ column: 'name', direction: 'asc' });
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const sortedIngredients = useMemo(() => {
if (!recipeQuery.data) return [];
const { column, direction } = ingredientSort;
return sortBy(recipeQuery.data.ingredients, direction, (ing) =>
column === 'name' ? ing.name : ing.amountGrams,
);
}, [recipeQuery.data, ingredientSort]);
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) {
return <Screen loading />;
}
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView
message={t('errors.loadRecipeFailed')}
onRetry={() => recipeQuery.refetch()}
/>
</Screen>
);
}
const recipe = recipeQuery.data;
return (
<>
<Stack.Screen options={{ title: recipe.name }} />
<Screen>
<Button title={t('common.edit')} variant="secondary" onPress={() => router.push(`/(main)/recipe/edit/${recipeId}`)} />
<View style={[styles.metaCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.category, { color: colors.brand }]}>
{categoryLabel(recipe.mealCategory, t)}
</Text>
<View style={styles.metaRow}>
{recipe.calories != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.calories')}: {t('recipes.kcal', { count: recipe.calories })}
</Text>
) : null}
{recipe.prepTimeMinutes != null ? (
<Text style={{ color: colors.textMuted }}>
{t('recipes.minutes', { count: recipe.prepTimeMinutes })}
</Text>
) : null}
</View>
<View style={styles.macros}>
{recipe.protein != null ? (
<Text style={{ color: colors.text }}>{t('recipes.protein')}: {recipe.protein}g</Text>
) : null}
{recipe.fat != null ? (
<Text style={{ color: colors.text }}>{t('recipes.fat')}: {recipe.fat}g</Text>
) : null}
{recipe.carbs != null ? (
<Text style={{ color: colors.text }}>{t('recipes.carbs')}: {recipe.carbs}g</Text>
) : null}
</View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{recipe.ingredients.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noIngredients')}</Text>
) : (
<>
<View style={styles.sortRow}>
<SortChip
label={`${t('ingredientCatalog.columns.name')}${sortIndicator(ingredientSort, 'name')}`}
active={ingredientSort.column === 'name'}
onPress={() => setIngredientSort((current) => nextSort(current, 'name'))}
/>
<SortChip
label={`${t('diet.grams')}${sortIndicator(ingredientSort, 'amount')}`}
active={ingredientSort.column === 'amount'}
onPress={() => setIngredientSort((current) => nextSort(current, 'amount'))}
/>
</View>
{sortedIngredients.map((ing) => (
<Text key={ing.id} style={[styles.line, { color: colors.text }]}>
{ing.name}
{ing.amountGrams != null ? `${ing.amountGrams}g` : ''}
{ing.unit ? ` ${ing.unit}` : ''}
</Text>
))}
</>
)}
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.preparation')}</Text>
{recipe.steps.length === 0 ? (
<Text style={{ color: colors.textMuted }}>{t('recipes.noSteps')}</Text>
) : (
recipe.steps.map((step) => (
<Text key={step.id} style={[styles.step, { color: colors.text }]}>
{step.stepNumber}. {step.description}
</Text>
))
)}
</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>
);
}
const styles = StyleSheet.create({
metaCard: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 8,
},
category: {
fontSize: 14,
fontWeight: '700',
textTransform: 'uppercase',
},
metaRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
macros: {
gap: 4,
},
section: {
fontSize: 18,
fontWeight: '700',
marginTop: 8,
},
sortRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 8,
},
sortChip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
line: {
fontSize: 15,
lineHeight: 22,
},
step: {
fontSize: 15,
lineHeight: 22,
marginBottom: 6,
},
});

View File

@@ -0,0 +1,202 @@
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { useEffect, 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 { fetchIngredientCatalogItems } from '@/src/api/ingredient-catalog.api';
import { updateRecipe } from '@/src/api/recipe-management.api';
import { fetchRecipeById } from '@/src/api/recipes.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 { MealCategory } from '@/src/types/recipe';
import { extractApiError } from '@/src/utils/apiError';
import { ingredientDisplayName } from '@/src/utils/ingredient-catalog.util';
import { ALL_CATEGORIES, categoryLabel, parseOptionalNumber } from '@/src/utils/recipe';
interface IngredientRow {
name: string;
amountGrams: string;
unit: string;
catalogItemId: number | null;
}
interface StepRow {
description: string;
}
export default function EditRecipeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const recipeId = Number(id);
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const queryClient = useQueryClient();
const recipeQuery = useQuery({
queryKey: ['recipe', recipeId],
queryFn: () => fetchRecipeById(recipeId),
enabled: Number.isFinite(recipeId),
});
const catalogQuery = useQuery({
queryKey: ['ingredient-catalog', 'all'],
queryFn: () => fetchIngredientCatalogItems(),
});
const [name, setName] = useState('');
const [category, setCategory] = useState<MealCategory>(MealCategory.Lunch);
const [prepTime, setPrepTime] = useState('');
const [calories, setCalories] = useState('');
const [protein, setProtein] = useState('');
const [fat, setFat] = useState('');
const [carbs, setCarbs] = useState('');
const [ingredients, setIngredients] = useState<IngredientRow[]>([]);
const [steps, setSteps] = useState<StepRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const recipe = recipeQuery.data;
if (!recipe) return;
setName(recipe.name);
setCategory(recipe.mealCategory);
setPrepTime(recipe.prepTimeMinutes?.toString() ?? '');
setCalories(recipe.calories?.toString() ?? '');
setProtein(recipe.protein?.toString() ?? '');
setFat(recipe.fat?.toString() ?? '');
setCarbs(recipe.carbs?.toString() ?? '');
setIngredients(
recipe.ingredients.length > 0
? recipe.ingredients.map((i) => ({
name: i.name,
amountGrams: i.amountGrams?.toString() ?? '',
unit: i.unit ?? '',
catalogItemId: i.catalogItemId ?? null,
}))
: [{ name: '', amountGrams: '', unit: '', catalogItemId: null }],
);
setSteps(
recipe.steps.length > 0
? recipe.steps.map((s) => ({ description: s.description }))
: [{ description: '' }],
);
}, [recipeQuery.data]);
const saveMutation = useMutation({
mutationFn: () => {
const filteredSteps = steps.filter((s) => s.description.trim());
return updateRecipe(recipeId, {
name: name.trim(),
mealCategory: category,
calories: parseOptionalNumber(calories),
protein: parseOptionalNumber(protein),
fat: parseOptionalNumber(fat),
carbs: parseOptionalNumber(carbs),
prepTimeMinutes: parseOptionalNumber(prepTime),
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, index) => ({
name: i.name.trim(),
amountGrams: parseOptionalNumber(i.amountGrams),
unit: i.unit,
catalogItemId: i.catalogItemId,
sortOrder: index,
})),
steps: filteredSteps.map((s, index) => ({ stepNumber: index + 1, description: s.description.trim() })),
});
},
onSuccess: (recipe) => {
queryClient.invalidateQueries({ queryKey: ['recipes'] });
queryClient.invalidateQueries({ queryKey: ['recipe', recipeId] });
Alert.alert(t('manage.title'), t('manage.updatedSuccess', { name: recipe.name }));
router.back();
},
onError: (err) => setError(extractApiError(err, t('errors.saveRecipeFailed'))),
});
if (!Number.isFinite(recipeId)) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} />
</Screen>
);
}
if (recipeQuery.isLoading) return <Screen loading />;
if (recipeQuery.isError || !recipeQuery.data) {
return (
<Screen>
<ErrorView message={t('errors.loadRecipeFailed')} onRetry={() => recipeQuery.refetch()} />
</Screen>
);
}
const catalogItems = catalogQuery.data ?? [];
return (
<>
<Stack.Screen options={{ title: t('manage.editRecipe') }} />
<Screen title={t('manage.editRecipe')} subtitle={recipeQuery.data.name}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Input label={t('manage.recipeName')} value={name} onChangeText={setName} />
<Text style={[styles.label, { color: colors.text }]}>{t('common.category')}</Text>
<View style={styles.chips}>
{ALL_CATEGORIES.map((cat) => (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
style={[styles.chip, { backgroundColor: category === cat ? colors.brand : colors.surface, borderColor: category === cat ? colors.brand : colors.border }]}
>
<Text style={{ color: category === cat ? '#fff' : colors.text, fontSize: 13 }}>{categoryLabel(cat, t)}</Text>
</Pressable>
))}
</View>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.prepTimeMin')} keyboardType="numeric" value={prepTime} onChangeText={setPrepTime} /></View>
<View style={styles.half}><Input label={t('manage.caloriesKcal')} keyboardType="numeric" value={calories} onChangeText={setCalories} /></View>
</View>
<Text style={[styles.section, { color: colors.text }]}>{t('recipes.ingredients')}</Text>
{ingredients.map((ing, index) => (
<View key={index} style={styles.block}>
<Input label={t('manage.ingredientName')} value={ing.name} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], name: v }; setIngredients(next); }} />
<Text style={[styles.label, { color: colors.text }]}>{t('manage.catalogItem')}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips}>
<Pressable onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: null }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId == null ? colors.brand : colors.surface, borderColor: ing.catalogItemId == null ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId == null ? '#fff' : colors.text, fontSize: 12 }}>{t('diet.noCatalogItem')}</Text>
</Pressable>
{catalogItems.map((item) => (
<Pressable key={item.id} onPress={() => { const next = [...ingredients]; next[index] = { ...next[index], catalogItemId: item.id, name: ingredientDisplayName(item, i18n.language) }; setIngredients(next); }} style={[styles.chip, { backgroundColor: ing.catalogItemId === item.id ? colors.brand : colors.surface, borderColor: ing.catalogItemId === item.id ? colors.brand : colors.border }]}>
<Text style={{ color: ing.catalogItemId === item.id ? '#fff' : colors.text, fontSize: 12 }}>{ingredientDisplayName(item, i18n.language)}</Text>
</Pressable>
))}
</ScrollView>
<View style={styles.row}>
<View style={styles.half}><Input label={t('manage.ingredientGrams')} keyboardType="numeric" value={ing.amountGrams} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], amountGrams: v }; setIngredients(next); }} /></View>
<View style={styles.half}><Input label={t('manage.ingredientUnit')} value={ing.unit} onChangeText={(v) => { const next = [...ingredients]; next[index] = { ...next[index], unit: v }; setIngredients(next); }} /></View>
</View>
</View>
))}
<Text style={[styles.section, { color: colors.text }]}>{t('manage.preparationSteps')}</Text>
{steps.map((step, index) => (
<Input key={index} label={`${index + 1}.`} value={step.description} onChangeText={(v) => { const next = [...steps]; next[index] = { description: v }; setSteps(next); }} multiline />
))}
{error ? <Text style={{ color: colors.danger }}>{error}</Text> : null}
<Button title={t('common.save')} loading={saveMutation.isPending} onPress={() => saveMutation.mutate()} />
</ScrollView>
</Screen>
</>
);
}
const styles = StyleSheet.create({
scroll: { gap: 12, paddingBottom: 32 },
section: { fontSize: 18, fontWeight: '700' },
label: { fontSize: 14, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 8 },
row: { flexDirection: 'row', gap: 10 },
half: { flex: 1 },
block: { gap: 8 },
});

View File

@@ -0,0 +1,39 @@
import { ScrollViewStyleReset } from 'expo-router/html';
import type { ReactNode } from 'react';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,47 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
export default function NotFoundScreen() {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<>
<Stack.Screen options={{ title: t('notFound.title') }} />
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.text }]}>{t('notFound.title')}</Text>
<Text style={[styles.description, { color: colors.textMuted }]}>
{t('notFound.description')}
</Text>
<Link href="/" style={[styles.link, { color: colors.brand }]}>
{t('notFound.backToDashboard')}
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
gap: 12,
},
title: {
fontSize: 22,
fontWeight: '700',
},
description: {
fontSize: 15,
textAlign: 'center',
},
link: {
marginTop: 8,
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,70 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import 'react-native-reanimated';
import { AuthProvider } from '@/src/context/AuthContext';
import { AppearanceProvider } from '@/src/context/AppearanceContext';
import { ReminderNotificationBridge } from '@/src/components/ReminderNotificationBridge';
import i18n, { initI18n } from '@/src/i18n';
export { ErrorBoundary } from 'expo-router';
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
export default function RootLayout() {
const [ready, setReady] = useState(false);
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
(async () => {
await initI18n();
setReady(true);
})();
}, []);
useEffect(() => {
if (loaded && ready) {
SplashScreen.hideAsync();
}
}, [loaded, ready]);
if (!loaded || !ready) {
return null;
}
return (
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<AppearanceProvider>
<AuthProvider>
<ReminderNotificationBridge />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
</Stack>
</AuthProvider>
</AppearanceProvider>
</QueryClientProvider>
</I18nextProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { Redirect } from 'expo-router';
import { useAuth } from '@/src/context/AuthContext';
import { Screen } from '@/src/components/Screen';
export default function Index() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <Screen loading />;
}
if (isAuthenticated) {
return <Redirect href="/(main)/(tabs)" />;
}
return <Redirect href="/(auth)/login" />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,76 @@
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@@ -0,0 +1,22 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import type { ComponentProps } from 'react';
import { Platform } from 'react-native';
export function ExternalLink(props: Omit<ComponentProps<typeof Link>, 'href'> & { href: string }) {
return (
<Link
target="_blank"
{...props}
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View File

@@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import { useColorScheme } from './useColorScheme';
import Colors from '@/constants/Colors';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme();
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = useState<S | C>(server);
useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@@ -0,0 +1,6 @@
import { useColorScheme as useColorSchemeCore } from 'react-native';
export const useColorScheme = () => {
const coreScheme = useColorSchemeCore();
return coreScheme === 'unspecified' ? 'light' : coreScheme;
};

View File

@@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@@ -0,0 +1,19 @@
const tintColorLight = '#2f95dc';
const tintColorDark = '#fff';
export default {
light: {
text: '#000',
background: '#fff',
tint: tintColorLight,
tabIconDefault: '#ccc',
tabIconSelected: tintColorLight,
},
dark: {
text: '#fff',
background: '#000',
tint: tintColorDark,
tabIconDefault: '#ccc',
tabIconSelected: tintColorDark,
},
};

7875
meal-plan-frontend-mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
{
"name": "meal-plan-frontend-mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/react-query": "^5.101.0",
"expo": "~56.0.12",
"expo-constants": "~56.0.18",
"expo-document-picker": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-linking": "~56.0.14",
"expo-notifications": "~56.0.18",
"expo-print": "~56.0.4",
"expo-router": "~56.2.11",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.18",
"expo-splash-screen": "~56.0.10",
"expo-status-bar": "~56.0.4",
"expo-symbols": "~56.0.6",
"expo-web-browser": "~56.0.5",
"i18next": "^26.3.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-i18next": "^17.0.8",
"react-native": "0.85.3",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
}

View File

@@ -0,0 +1,40 @@
import { apiFetch, saveTokens } from '@/src/api/client';
import type { LoginRequest, RegisterRequest, TokenResponse, UserInfo } from '@/src/types/auth';
export async function login(payload: LoginRequest): Promise<TokenResponse> {
const tokens = await apiFetch<TokenResponse>('/auth/login', {
method: 'POST',
body: payload,
skipAuth: true,
});
await saveTokens(tokens);
return tokens;
}
export async function register(payload: RegisterRequest): Promise<TokenResponse> {
const tokens = await apiFetch<TokenResponse>('/auth/register', {
method: 'POST',
body: payload,
skipAuth: true,
});
await saveTokens(tokens);
return tokens;
}
export async function fetchCurrentUser(): Promise<UserInfo> {
return apiFetch<UserInfo>('/auth/me');
}
export async function logout(refresh: string | null): Promise<void> {
if (refresh) {
try {
await apiFetch('/auth/logout', {
method: 'POST',
body: { refreshToken: refresh },
skipAuth: true,
});
} catch {
// best effort
}
}
}

View File

@@ -0,0 +1,135 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getApiBaseUrl } from '@/src/utils/config';
import { ApiError } from '@/src/utils/apiError';
import type { TokenResponse } from '@/src/types/auth';
const ACCESS_KEY = 'dailymeals.access';
const REFRESH_KEY = 'dailymeals.refresh';
let accessToken: string | null = null;
let refreshToken: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
export async function loadStoredTokens(): Promise<void> {
accessToken = await AsyncStorage.getItem(ACCESS_KEY);
refreshToken = await AsyncStorage.getItem(REFRESH_KEY);
}
export async function saveTokens(tokens: TokenResponse): Promise<void> {
accessToken = tokens.accessToken;
refreshToken = tokens.refreshToken;
await AsyncStorage.multiSet([
[ACCESS_KEY, tokens.accessToken],
[REFRESH_KEY, tokens.refreshToken],
]);
}
export async function clearTokens(): Promise<void> {
accessToken = null;
refreshToken = null;
await AsyncStorage.multiRemove([ACCESS_KEY, REFRESH_KEY]);
}
export function getAccessToken(): string | null {
return accessToken;
}
export async function getRefreshToken(): Promise<string | null> {
if (refreshToken) return refreshToken;
return AsyncStorage.getItem(REFRESH_KEY);
}
async function refreshAccessToken(): Promise<string | null> {
const storedRefresh = refreshToken ?? (await AsyncStorage.getItem(REFRESH_KEY));
if (!storedRefresh) return null;
refreshToken = storedRefresh;
const res = await fetch(`${getApiBaseUrl()}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: storedRefresh }),
});
if (!res.ok) return null;
const tokens = (await res.json()) as TokenResponse;
await saveTokens(tokens);
return tokens.accessToken;
}
async function performRefresh(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
skipAuth?: boolean;
retried?: boolean;
}
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
const { body, skipAuth, retried, headers, ...rest } = options;
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
...(headers as Record<string, string>),
};
if (!(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json';
}
if (!skipAuth && accessToken) {
requestHeaders.Authorization = `Bearer ${accessToken}`;
}
const res = await fetch(url, {
...rest,
headers: requestHeaders,
body: body instanceof FormData ? body : body !== undefined ? JSON.stringify(body) : undefined,
});
const isAuthPath =
path.includes('/auth/login') ||
path.includes('/auth/register') ||
path.includes('/auth/refresh');
if (res.status === 401 && !skipAuth && !retried && !isAuthPath) {
const newToken = await performRefresh();
if (newToken) {
return apiFetch<T>(path, { ...options, retried: true });
}
await clearTokens();
throw new ApiError('Session expired.', 401);
}
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const data = (await res.json()) as { error?: string; title?: string };
const genericTitles = new Set(['An unexpected error occurred.', 'Internal Server Error']);
if (data.error) {
message = data.error;
} else if (data.title && !genericTitles.has(data.title)) {
message = data.title;
}
} catch {
// ignore parse errors
}
throw new ApiError(message, res.status);
}
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return (await res.blob()) as T;
}

View File

@@ -0,0 +1,89 @@
import { apiFetch } from '@/src/api/client';
import type {
CommitDietPlanResult,
DietGeneratorSession,
DietUserProfile,
MacroProposal,
} from '@/src/types/generator';
export async function fetchDietProfile(): Promise<DietUserProfile | null> {
const data = await apiFetch<DietUserProfile>('/api/diet-generator/profile');
return data.heightCm ? data : null;
}
export async function saveDietProfile(profile: DietUserProfile): Promise<DietUserProfile> {
return apiFetch<DietUserProfile>('/api/diet-generator/profile', {
method: 'PUT',
body: profile,
});
}
export async function createDietSession(input: {
planStartDate?: string;
planDayCount?: number;
calorieToleranceKcal?: number;
}): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>('/api/diet-generator/sessions', {
method: 'POST',
body: input,
});
}
export async function fetchDietSession(id: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${id}`);
}
export async function proposeDietMacros(sessionId: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/propose-macros`, {
method: 'POST',
});
}
export async function acceptDietMacros(
sessionId: number,
macros?: Partial<MacroProposal>,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/accept-macros`, {
method: 'POST',
body: {
calories: macros?.calories,
proteinG: macros?.proteinG,
fatG: macros?.fatG,
carbsG: macros?.carbsG,
},
});
}
export async function generateDietPlan(sessionId: number): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/generate-plan`, {
method: 'POST',
});
}
export async function regenerateDietMeal(
sessionId: number,
planDate: string,
mealCategory: number,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/regenerate-meal`, {
method: 'POST',
body: { planDate, mealCategory },
});
}
export async function toggleDietMealLock(
sessionId: number,
mealId: number,
isLocked: boolean,
): Promise<DietGeneratorSession> {
return apiFetch<DietGeneratorSession>(`/api/diet-generator/sessions/${sessionId}/meals/${mealId}`, {
method: 'PATCH',
body: { isLocked },
});
}
export async function commitDietPlan(sessionId: number): Promise<CommitDietPlanResult> {
return apiFetch<CommitDietPlanResult>(`/api/diet-generator/sessions/${sessionId}/commit`, {
method: 'POST',
});
}

View File

@@ -0,0 +1,107 @@
import { apiFetch } from '@/src/api/client';
import type {
CopyMealsResult,
CreateDietReminderInput,
DailySummary,
DietReminder,
DietSuggestion,
DrinkCatalogItem,
DueReminder,
FavoriteCatalogItem,
FavoriteRecipe,
HistoryDay,
LogDrinkInput,
LogMealInput,
MealConsumption,
MealPreview,
RecentMeal,
UpdateDietReminderInput,
UserDailyGoals,
} from '@/src/types/diet';
import { todayIso } from '@/src/types/diet';
import { normalizeDailySummary } from '@/src/utils/diet-normalize';
export async function fetchDailySummary(date = todayIso()): Promise<DailySummary> {
const data = await apiFetch<unknown>(`/diet/summary?date=${date}`);
return normalizeDailySummary(data);
}
export async function fetchGoals(): Promise<UserDailyGoals> {
return apiFetch<UserDailyGoals>('/diet/goals');
}
export async function saveGoals(goals: UserDailyGoals): Promise<UserDailyGoals> {
return apiFetch<UserDailyGoals>('/diet/goals', { method: 'PUT', body: goals });
}
export async function logMeal(input: LogMealInput): Promise<MealConsumption> {
return apiFetch<MealConsumption>('/diet/meals', { method: 'POST', body: input });
}
export async function previewMeal(input: LogMealInput, date = todayIso()): Promise<MealPreview> {
return apiFetch<MealPreview>(`/diet/meals/preview?date=${date}`, { method: 'POST', body: input });
}
export async function fetchMealSuggestions(date = todayIso(), limit = 5): Promise<DietSuggestion[]> {
return apiFetch<DietSuggestion[]>(`/diet/suggestions?date=${date}&limit=${limit}`);
}
export async function deleteMeal(id: number): Promise<void> {
await apiFetch(`/diet/meals/${id}`, { method: 'DELETE' });
}
export async function fetchDrinkCatalog(): Promise<DrinkCatalogItem[]> {
return apiFetch<DrinkCatalogItem[]>('/diet/drinks/catalog');
}
export async function logDrink(input: LogDrinkInput): Promise<void> {
await apiFetch('/diet/drinks', { method: 'POST', body: input });
}
export async function deleteDrink(id: number): Promise<void> {
await apiFetch(`/diet/drinks/${id}`, { method: 'DELETE' });
}
export async function fetchReminders(): Promise<DietReminder[]> {
return apiFetch<DietReminder[]>('/diet/reminders');
}
export async function createReminder(input: CreateDietReminderInput): Promise<DietReminder> {
return apiFetch<DietReminder>('/diet/reminders', { method: 'POST', body: input });
}
export async function updateReminder(id: number, input: UpdateDietReminderInput): Promise<DietReminder> {
return apiFetch<DietReminder>(`/diet/reminders/${id}`, { method: 'PUT', body: input });
}
export async function deleteReminder(id: number): Promise<void> {
await apiFetch(`/diet/reminders/${id}`, { method: 'DELETE' });
}
export async function fetchDueReminders(date?: string, time?: string): Promise<DueReminder[]> {
const params = new URLSearchParams();
if (date) params.set('date', date);
if (time) params.set('time', time);
const qs = params.toString();
return apiFetch<DueReminder[]>(`/diet/reminders/due${qs ? `?${qs}` : ''}`);
}
export async function fetchHistory(from: string, to: string): Promise<HistoryDay[]> {
return apiFetch<HistoryDay[]>(`/diet/history?from=${from}&to=${to}`);
}
export async function fetchRecentMeals(limit = 10): Promise<RecentMeal[]> {
return apiFetch<RecentMeal[]>(`/diet/meals/recent?limit=${limit}`);
}
export async function copyYesterdayMeals(date = todayIso()): Promise<CopyMealsResult> {
return apiFetch<CopyMealsResult>(`/diet/meals/copy-yesterday?date=${date}`, { method: 'POST' });
}
export async function fetchFavoriteRecipes(): Promise<FavoriteRecipe[]> {
return apiFetch<FavoriteRecipe[]>('/diet/favorites/recipes');
}
export async function fetchFavoriteCatalogItems(): Promise<FavoriteCatalogItem[]> {
return apiFetch<FavoriteCatalogItem[]>('/diet/favorites/catalog');
}

View File

@@ -0,0 +1,44 @@
import { apiFetch } from '@/src/api/client';
import type {
IngredientCategory,
IngredientNutritionItem,
UpsertIngredientNutritionItemInput,
} from '@/src/types/ingredient-catalog';
export async function fetchIngredientCategories(): Promise<IngredientCategory[]> {
return apiFetch<IngredientCategory[]>('/ingredient-catalog/categories');
}
export async function fetchIngredientCatalogItems(params?: {
category?: string;
search?: string;
}): Promise<IngredientNutritionItem[]> {
const query = new URLSearchParams();
if (params?.category) query.set('category', params.category);
if (params?.search) query.set('search', params.search);
const suffix = query.toString() ? `?${query.toString()}` : '';
return apiFetch<IngredientNutritionItem[]>(`/ingredient-catalog${suffix}`);
}
export async function createIngredientCatalogItem(
input: UpsertIngredientNutritionItemInput,
): Promise<IngredientNutritionItem> {
return apiFetch<IngredientNutritionItem>('/ingredient-catalog', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function updateIngredientCatalogItem(
id: number,
input: UpsertIngredientNutritionItemInput,
): Promise<IngredientNutritionItem> {
return apiFetch<IngredientNutritionItem>(`/ingredient-catalog/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export async function deleteIngredientCatalogItem(id: number): Promise<void> {
await apiFetch<void>(`/ingredient-catalog/${id}`, { method: 'DELETE' });
}

View File

@@ -0,0 +1,22 @@
import { apiFetch } from '@/src/api/client';
import type {
MealPlanEntry,
ShoppingListItem,
UpsertMealPlanEntryInput,
} from '@/src/types/meal-plan';
export async function fetchMealPlanEntries(from: string, to: string): Promise<MealPlanEntry[]> {
return apiFetch<MealPlanEntry[]>(`/meal-plan?from=${from}&to=${to}`);
}
export async function upsertMealPlanEntry(input: UpsertMealPlanEntryInput): Promise<MealPlanEntry> {
return apiFetch<MealPlanEntry>('/meal-plan', { method: 'PUT', body: input });
}
export async function deleteMealPlanEntry(id: number): Promise<void> {
await apiFetch(`/meal-plan/${id}`, { method: 'DELETE' });
}
export async function fetchShoppingList(from: string, to: string): Promise<ShoppingListItem[]> {
return apiFetch<ShoppingListItem[]>(`/meal-plan/shopping-list?from=${from}&to=${to}`);
}

View File

@@ -0,0 +1,59 @@
import { apiFetch } from '@/src/api/client';
import type {
CommitRecipesResult,
GeneratedRecipeDraft,
RecipeGeneratorConstraints,
RecipeGeneratorSession,
} from '@/src/types/generator';
export async function createRecipeSession(
constraints: RecipeGeneratorConstraints,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>('/api/recipe-generator/sessions', {
method: 'POST',
body: constraints,
});
}
export async function generateRecipeDrafts(sessionId: number, count = 3): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/generate`, {
method: 'POST',
body: { count },
});
}
export async function regenerateRecipePart(
sessionId: number,
draftId: string,
mode: 'ingredients' | 'steps' | 'full',
instruction?: string,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/regenerate`, {
method: 'POST',
body: { draftId, mode, instruction },
});
}
export async function removeRecipeDraft(sessionId: number, draftId: string): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(
`/api/recipe-generator/sessions/${sessionId}/drafts/${encodeURIComponent(draftId)}`,
{ method: 'DELETE' },
);
}
export async function commitRecipes(sessionId: number, draftIds?: string[]): Promise<CommitRecipesResult> {
return apiFetch<CommitRecipesResult>(`/api/recipe-generator/sessions/${sessionId}/commit`, {
method: 'POST',
body: { draftIds: draftIds?.length ? draftIds : undefined },
});
}
export async function updateRecipeDraft(
sessionId: number,
draft: GeneratedRecipeDraft,
): Promise<RecipeGeneratorSession> {
return apiFetch<RecipeGeneratorSession>(`/api/recipe-generator/sessions/${sessionId}/draft`, {
method: 'PUT',
body: draft,
});
}

View File

@@ -0,0 +1,47 @@
import { apiFetch } from '@/src/api/client';
import type { CreateRecipeInput, RecipeDetail } from '@/src/types/recipe';
import type { RecipeImportResult } from '@/src/types/recipe-management';
function toPayload(input: CreateRecipeInput) {
return {
name: input.name,
mealCategory: input.mealCategory,
calories: input.calories,
protein: input.protein,
fat: input.fat,
carbs: input.carbs,
prepTimeMinutes: input.prepTimeMinutes,
ingredients: input.ingredients.map((i, index) => ({
name: i.name,
amountGrams: i.amountGrams,
unit: i.unit.trim() || null,
catalogItemId: i.catalogItemId ?? null,
sortOrder: i.sortOrder || index,
})),
steps: input.steps.map((s) => ({
stepNumber: s.stepNumber,
description: s.description,
})),
};
}
export async function updateRecipe(id: number, input: CreateRecipeInput): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>(`/recipes/${id}`, { method: 'PUT', body: toPayload(input) });
}
export async function downloadImportTemplate(): Promise<Blob> {
return apiFetch<Blob>('/recipes/import/template');
}
export async function importRecipesFromExcel(uri: string, fileName: string): Promise<RecipeImportResult> {
const formData = new FormData();
formData.append('file', {
uri,
name: fileName,
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
} as unknown as Blob);
return apiFetch<RecipeImportResult>('/recipes/import/excel', {
method: 'POST',
body: formData,
});
}

View File

@@ -0,0 +1,52 @@
import { apiFetch } from '@/src/api/client';
import type {
CategoryCount,
CreateRecipeInput,
MealCategory,
RecipeDetail,
RecipeListItem,
} from '@/src/types/recipe';
export async function fetchRecipes(params?: {
category?: MealCategory;
search?: string;
ingredient?: string;
}): Promise<RecipeListItem[]> {
const query = new URLSearchParams();
if (params?.category != null) query.set('category', String(params.category));
if (params?.search) query.set('search', params.search);
if (params?.ingredient) query.set('ingredient', params.ingredient);
const qs = query.toString();
return apiFetch<RecipeListItem[]>(`/recipes${qs ? `?${qs}` : ''}`);
}
export async function fetchRecipeById(id: number): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>(`/recipes/${id}`);
}
export async function fetchCategories(): Promise<CategoryCount[]> {
return apiFetch<CategoryCount[]>('/recipes/categories');
}
export async function createRecipe(input: CreateRecipeInput): Promise<RecipeDetail> {
return apiFetch<RecipeDetail>('/recipes', {
method: 'POST',
body: {
name: input.name,
mealCategory: input.mealCategory,
calories: input.calories,
protein: input.protein,
fat: input.fat,
carbs: input.carbs,
prepTimeMinutes: input.prepTimeMinutes,
ingredients: input.ingredients.map((i, index) => ({
name: i.name,
amountGrams: i.amountGrams,
unit: i.unit.trim() || null,
catalogItemId: i.catalogItemId ?? null,
sortOrder: i.sortOrder || index,
})),
steps: input.steps,
},
});
}

View File

@@ -0,0 +1,90 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
type PressableProps,
} from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
interface ButtonProps extends Omit<PressableProps, 'style'> {
title: string;
loading?: boolean;
variant?: Variant;
style?: PressableProps['style'];
}
export function Button({
title,
loading,
variant = 'primary',
disabled,
style: externalStyle,
...rest
}: ButtonProps) {
const { colors } = useAppearance();
const isDisabled = disabled || loading;
const backgroundColor =
variant === 'primary'
? colors.brand
: variant === 'danger'
? colors.danger
: variant === 'secondary'
? colors.brandLight
: 'transparent';
const textColor =
variant === 'primary' || variant === 'danger'
? '#ffffff'
: variant === 'secondary'
? colors.brand
: colors.brand;
return (
<Pressable
accessibilityRole="button"
disabled={isDisabled}
style={(state) => {
const resolvedExternal =
typeof externalStyle === 'function' ? externalStyle(state) : externalStyle;
return [
styles.base,
{
backgroundColor,
borderColor: variant === 'ghost' ? colors.border : backgroundColor,
opacity: isDisabled ? 0.6 : state.pressed ? 0.85 : 1,
},
variant === 'ghost' && styles.ghost,
resolvedExternal,
];
}}
{...rest}
>
{loading ? (
<ActivityIndicator color={textColor} />
) : (
<Text style={[styles.label, { color: textColor }]}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 48,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
ghost: {
borderWidth: 1,
},
label: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,81 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import { addDays, formatDisplayDate } from '@/src/utils/date';
import { todayIso } from '@/src/types/diet';
interface DateNavigatorProps {
date: string;
onChange: (date: string) => void;
}
export function DateNavigator({ date, onChange }: DateNavigatorProps) {
const { t, i18n } = useTranslation();
const { colors } = useAppearance();
const isToday = date === todayIso();
return (
<View style={styles.row}>
<Pressable
onPress={() => onChange(addDays(date, -1))}
style={[styles.btn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontWeight: '600' }}></Text>
</Pressable>
<View style={[styles.center, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontWeight: '700' }}>
{formatDisplayDate(date, i18n.language)}
</Text>
{isToday ? (
<Text style={{ color: colors.brand, fontSize: 11, fontWeight: '600' }}>{t('diet.today')}</Text>
) : null}
</View>
<Pressable
onPress={() => onChange(addDays(date, 1))}
style={[styles.btn, { borderColor: colors.border, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.text, fontWeight: '600' }}></Text>
</Pressable>
{!isToday ? (
<Pressable
onPress={() => onChange(todayIso())}
style={[styles.todayBtn, { borderColor: colors.brand, backgroundColor: colors.surface }]}
>
<Text style={{ color: colors.brand, fontWeight: '600', fontSize: 12 }}>{t('diet.goToday')}</Text>
</Pressable>
) : null}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
btn: {
borderWidth: 1,
borderRadius: 10,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
center: {
flex: 1,
minWidth: 120,
borderWidth: 1,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 12,
alignItems: 'center',
},
todayBtn: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
});

View File

@@ -0,0 +1,29 @@
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import { Button } from '@/src/components/Button';
export function ErrorView({ message, onRetry }: { message: string; onRetry?: () => void }) {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<View style={[styles.wrap, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.message, { color: colors.danger }]}>{message}</Text>
{onRetry ? <Button title={t('common.tryAgain')} variant="secondary" onPress={onRetry} /> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
borderWidth: 1,
borderRadius: 14,
padding: 16,
gap: 12,
},
message: {
fontSize: 15,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Alert, StyleSheet, Text, View } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { downloadImportTemplate, importRecipesFromExcel } from '@/src/api/recipe-management.api';
import { Button } from '@/src/components/Button';
import { useAppearance } from '@/src/context/AppearanceContext';
import { extractApiError } from '@/src/utils/apiError';
interface ExcelImportPanelProps {
onImported?: () => void;
}
export function ExcelImportPanel({ onImported }: ExcelImportPanelProps) {
const { t } = useTranslation();
const { colors } = useAppearance();
const [selectedFile, setSelectedFile] = useState<{ uri: string; name: string } | null>(null);
const [downloading, setDownloading] = useState(false);
const importMutation = useMutation({
mutationFn: () => {
if (!selectedFile) throw new Error('No file');
return importRecipesFromExcel(selectedFile.uri, selectedFile.name);
},
onSuccess: (result) => {
setSelectedFile(null);
onImported?.();
Alert.alert(
t('manage.importComplete'),
`${t('manage.createdCount', { count: result.createdCount })} · ${t('manage.skippedCount', { count: result.skippedCount })}`,
);
},
});
async function handleDownloadTemplate() {
setDownloading(true);
try {
const blob = await downloadImportTemplate();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
const dataUrl = reader.result as string;
resolve(dataUrl.split(',')[1] ?? '');
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const path = `${FileSystem.cacheDirectory}DailyMeals-recipe-import-template.xlsx`;
await FileSystem.writeAsStringAsync(path, base64, { encoding: FileSystem.EncodingType.Base64 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path);
}
} catch (err) {
Alert.alert(t('errors.generic'), extractApiError(err, t('errors.saveRecipeFailed')));
} finally {
setDownloading(false);
}
}
async function handlePickFile() {
const result = await DocumentPicker.getDocumentAsync({
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
copyToCacheDirectory: true,
});
if (!result.canceled && result.assets[0]) {
setSelectedFile({ uri: result.assets[0].uri, name: result.assets[0].name });
}
}
return (
<View style={styles.container}>
<Text style={{ color: colors.text, fontWeight: '700', fontSize: 16 }}>{t('manage.importTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}>{t('manage.importDescription')}</Text>
<Button
title={t('manage.downloadTemplate')}
variant="secondary"
loading={downloading}
onPress={() => void handleDownloadTemplate()}
/>
<Button title={t('manage.uploadLabel')} variant="secondary" onPress={() => void handlePickFile()} />
{selectedFile ? (
<Text style={{ color: colors.text, fontSize: 13 }}>{selectedFile.name}</Text>
) : null}
{importMutation.isError ? (
<Text style={{ color: colors.danger }}>{extractApiError(importMutation.error, t('errors.saveRecipeFailed'))}</Text>
) : null}
<Button
title={importMutation.isPending ? t('manage.importing') : t('manage.importRecipes')}
loading={importMutation.isPending}
disabled={!selectedFile}
onPress={() => importMutation.mutate()}
/>
<View style={[styles.hint, { borderColor: colors.border }]}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{t('manage.excelFormatTitle')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelRecipesSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelIngredientsSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13 }}> {t('manage.excelStepsSheet')}</Text>
<Text style={{ color: colors.textMuted, fontSize: 13, marginTop: 4 }}>{t('manage.duplicateSkipped')}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 12 },
hint: { borderWidth: 1, borderStyle: 'dashed', borderRadius: 12, padding: 12, gap: 4 },
});

View File

@@ -0,0 +1,51 @@
import { StyleSheet, Text, TextInput, View, type TextInputProps } from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
}
export function Input({ label, error, style, ...rest }: InputProps) {
const { colors } = useAppearance();
return (
<View style={styles.wrap}>
{label ? <Text style={[styles.label, { color: colors.text }]}>{label}</Text> : null}
<TextInput
placeholderTextColor={colors.textMuted}
style={[
styles.input,
{
backgroundColor: colors.surface,
borderColor: error ? colors.danger : colors.border,
color: colors.text,
},
style,
]}
{...rest}
/>
{error ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
gap: 6,
},
label: {
fontSize: 14,
fontWeight: '600',
},
input: {
minHeight: 48,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
fontSize: 16,
},
error: {
fontSize: 13,
},
});

View File

@@ -0,0 +1,80 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useAppearance } from '@/src/context/AppearanceContext';
import type { RecipeListItem } from '@/src/types/recipe';
import { categoryLabel } from '@/src/utils/recipe';
interface RecipeCardProps {
recipe: RecipeListItem;
onPress: () => void;
}
export function RecipeCard({ recipe, onPress }: RecipeCardProps) {
const { t } = useTranslation();
const { colors } = useAppearance();
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.card,
{
backgroundColor: colors.surface,
borderColor: colors.border,
opacity: pressed ? 0.9 : 1,
},
]}
>
<Text style={[styles.name, { color: colors.text }]}>{recipe.name}</Text>
<Text style={[styles.meta, { color: colors.textMuted }]}>
{categoryLabel(recipe.mealCategory, t)}
{recipe.calories != null ? ` · ${t('recipes.kcal', { count: recipe.calories })}` : ''}
{recipe.prepTimeMinutes != null
? ` · ${t('recipes.minutes', { count: recipe.prepTimeMinutes })}`
: ''}
</Text>
</Pressable>
);
}
export function EmptyState({ title, description }: { title: string; description?: string }) {
const { colors } = useAppearance();
return (
<View style={[styles.empty, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.emptyTitle, { color: colors.text }]}>{title}</Text>
{description ? (
<Text style={[styles.emptyDesc, { color: colors.textMuted }]}>{description}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 4,
},
name: {
fontSize: 17,
fontWeight: '600',
},
meta: {
fontSize: 14,
},
empty: {
borderWidth: 1,
borderRadius: 14,
padding: 20,
gap: 6,
},
emptyTitle: {
fontSize: 17,
fontWeight: '600',
},
emptyDesc: {
fontSize: 14,
lineHeight: 20,
},
});

View File

@@ -0,0 +1,6 @@
import { useReminderNotifications } from '@/src/hooks/useReminderNotifications';
export function ReminderNotificationBridge() {
useReminderNotifications();
return null;
}

View File

@@ -0,0 +1,57 @@
import { ActivityIndicator, StyleSheet, Text, View, type ViewProps } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppearance } from '@/src/context/AppearanceContext';
interface ScreenProps extends ViewProps {
title?: string;
subtitle?: string;
loading?: boolean;
}
export function Screen({ title, subtitle, loading, children, style, ...rest }: ScreenProps) {
const { colors } = useAppearance();
return (
<SafeAreaView style={[styles.safe, { backgroundColor: colors.background }]} edges={['top']}>
<View style={[styles.container, style]} {...rest}>
{title ? <Text style={[styles.title, { color: colors.text }]}>{title}</Text> : null}
{subtitle ? (
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{subtitle}</Text>
) : null}
{loading ? (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.brand} />
</View>
) : (
children
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
},
container: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 8,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
},
subtitle: {
fontSize: 15,
lineHeight: 22,
marginBottom: 4,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,49 @@
import { StyleSheet, Text, View } from 'react-native';
import { useAppearance } from '@/src/context/AppearanceContext';
interface BarChartItem {
label: string;
value: number;
}
interface SimpleBarChartProps {
data: BarChartItem[];
unit?: string;
maxValue?: number;
}
export function SimpleBarChart({ data, unit = '', maxValue }: SimpleBarChartProps) {
const { colors } = useAppearance();
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
return (
<View style={styles.container}>
{data.map((item) => {
const pct = Math.min((item.value / max) * 100, 100);
return (
<View key={item.label} style={styles.row}>
<Text style={[styles.label, { color: colors.textMuted }]} numberOfLines={1}>
{item.label}
</Text>
<View style={[styles.track, { backgroundColor: colors.background }]}>
<View style={[styles.fill, { width: `${pct}%`, backgroundColor: colors.brand }]} />
</View>
<Text style={[styles.value, { color: colors.text }]}>
{item.value}
{unit}
</Text>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
label: { width: 52, fontSize: 11, fontWeight: '600' },
track: { flex: 1, height: 10, borderRadius: 999, overflow: 'hidden' },
fill: { height: '100%', borderRadius: 999 },
value: { width: 56, fontSize: 11, fontWeight: '600', textAlign: 'right' },
});

View File

@@ -0,0 +1,61 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
APPEARANCE_OPTIONS,
getThemeColors,
type AppearanceId,
type ThemeColors,
} from '@/src/theme/appearance';
const STORAGE_KEY = 'dailymeals.appearance';
interface AppearanceContextValue {
appearanceId: AppearanceId;
colors: ThemeColors;
setAppearance: (id: AppearanceId) => Promise<void>;
}
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export function AppearanceProvider({ children }: { children: ReactNode }) {
const [appearanceId, setAppearanceId] = useState<AppearanceId>('forest');
useEffect(() => {
(async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored && APPEARANCE_OPTIONS.some((o) => o.id === stored)) {
setAppearanceId(stored as AppearanceId);
}
})();
}, []);
const setAppearance = useCallback(async (id: AppearanceId) => {
setAppearanceId(id);
await AsyncStorage.setItem(STORAGE_KEY, id);
}, []);
const value = useMemo(
() => ({
appearanceId,
colors: getThemeColors(appearanceId),
setAppearance,
}),
[appearanceId, setAppearance],
);
return <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) throw new Error('useAppearance must be used within AppearanceProvider');
return ctx;
}

View File

@@ -0,0 +1,101 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
clearTokens,
getAccessToken,
getRefreshToken,
loadStoredTokens,
saveTokens,
} from '@/src/api/client';
import * as authApi from '@/src/api/auth.api';
import type { LoginRequest, RegisterRequest, UserInfo } from '@/src/types/auth';
interface AuthContextValue {
user: UserInfo | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (payload: LoginRequest) => Promise<void>;
register: (payload: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshSession = useCallback(async () => {
await loadStoredTokens();
if (!getAccessToken()) {
setUser(null);
return;
}
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
useEffect(() => {
(async () => {
try {
await refreshSession();
} catch {
await clearTokens();
setUser(null);
} finally {
setIsLoading(false);
}
})();
}, [refreshSession]);
const login = useCallback(async (payload: LoginRequest) => {
await authApi.login(payload);
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
const register = useCallback(async (payload: RegisterRequest) => {
await authApi.register(payload);
const me = await authApi.fetchCurrentUser();
setUser(me);
}, []);
const logout = useCallback(async () => {
const refresh = await getRefreshToken();
await authApi.logout(refresh);
await clearTokens();
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: Boolean(user && getAccessToken()),
login,
register,
logout,
refreshSession,
}),
[user, isLoading, login, register, logout, refreshSession],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
// Re-export for token refresh after external login
export { saveTokens };

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useAuth } from '@/src/context/AuthContext';
import { fetchDueReminders } from '@/src/api/diet.api';
import { todayIso } from '@/src/types/diet';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
const POLL_MS = 60_000;
function currentTimeHHmm(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
export function useReminderNotifications() {
const { isAuthenticated } = useAuth();
const notifiedRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
const poll = async () => {
if (cancelled) return;
try {
const due = await fetchDueReminders(todayIso(), currentTimeHHmm());
for (const reminder of due) {
if (notifiedRef.current.has(reminder.id)) continue;
notifiedRef.current.add(reminder.id);
await Notifications.scheduleNotificationAsync({
content: {
title: reminder.title,
body: reminder.message ?? undefined,
},
trigger: null,
});
}
} catch {
// ignore polling errors
}
};
void (async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted' || cancelled) return;
await poll();
interval = setInterval(() => void poll(), POLL_MS);
})();
return () => {
cancelled = true;
if (interval) clearInterval(interval);
notifiedRef.current.clear();
};
}, [isAuthenticated]);
}

View File

@@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
import en from '@/src/i18n/locales/en.json';
import pl from '@/src/i18n/locales/pl.json';
const LANG_KEY = 'dailymeals.lang';
export async function initI18n() {
const stored = await AsyncStorage.getItem(LANG_KEY);
const lng = stored === 'pl' ? 'pl' : 'en';
await i18n.use(initReactI18next).init({
resources: { en: { translation: en }, pl: { translation: pl } },
lng,
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
}
export async function setAppLanguage(code: 'en' | 'pl') {
await AsyncStorage.setItem(LANG_KEY, code);
await i18n.changeLanguage(code);
}
export default i18n;

View File

@@ -0,0 +1,385 @@
{
"app": { "name": "DailyMeals" },
"language": {
"label": "Language",
"en": "English",
"pl": "Polish"
},
"appearance": {
"label": "Appearance",
"forest": "Forest Classic",
"forestDesc": "Green palette",
"ocean": "Ocean Fresh",
"oceanDesc": "Blue palette",
"sunset": "Sunset Kitchen",
"sunsetDesc": "Warm amber palette",
"berry": "Berry Modern",
"berryDesc": "Violet palette"
},
"nav": {
"dashboard": "Dashboard",
"allMeals": "All Meals",
"manageMeals": "Manage Meals",
"dietTracker": "My Diet",
"planner": "Meal Plan",
"shopping": "Shopping",
"ingredientCatalog": "Ingredients",
"dietGenerator": "Diet Generator",
"recipeGenerator": "Recipe Generator",
"settings": "Settings",
"logout": "Logout"
},
"categories": {
"breakfast": "Breakfast",
"secondBreakfast": "Second Breakfast",
"lunch": "Lunch",
"dinner": "Dinner",
"unknown": "Unknown"
},
"common": {
"loading": "Loading...",
"tryAgain": "Try again",
"back": "Back",
"add": "Add",
"category": "Category",
"all": "All",
"search": "Search",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"optional": "optional",
"recipeCount_one": "{{count}} recipe",
"recipeCount_other": "{{count}} recipes"
},
"auth": {
"signInSubtitle": "Sign in to plan your meals",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
"signIn": "Sign in",
"signingIn": "Signing in...",
"noAccount": "Don't have an account?",
"createOne": "Create one",
"createAccountTitle": "Create account",
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
"displayName": "Display name",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Confirm password",
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"hasAccount": "Already have an account?"
},
"validation": {
"emailRequired": "Email is required.",
"emailInvalid": "Enter a valid email address.",
"passwordRequired": "Password is required.",
"passwordMinLength": "Password must be at least 8 characters.",
"passwordUppercase": "Include at least one uppercase letter.",
"passwordLowercase": "Include at least one lowercase letter.",
"passwordDigit": "Include at least one digit.",
"confirmPasswordRequired": "Please confirm your password.",
"passwordsMismatch": "Passwords do not match.",
"recipeNameRequired": "Recipe name is required.",
"ingredientNameRequired": "Ingredient name is required.",
"stepDescriptionRequired": "Step description is required.",
"stepsMin": "Add at least one preparation step."
},
"errors": {
"generic": "Something went wrong.",
"signInFailed": "Unable to sign in.",
"registerFailed": "Unable to create account.",
"loadCategoriesFailed": "Failed to load categories.",
"loadRecipesFailed": "Failed to load recipes.",
"loadRecipeFailed": "Failed to load recipe.",
"saveRecipeFailed": "Failed to save recipe.",
"loadDietFailed": "Failed to load diet data.",
"loadHistoryFailed": "Failed to load diet history.",
"loadPlannerFailed": "Failed to load meal plan.",
"loadShoppingFailed": "Failed to load shopping list.",
"pdfGenerateFailed": "PDF generation failed.",
"pdfNothingToExport": "Nothing to export.",
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
"saveIngredientFailed": "Failed to save ingredient."
},
"dashboard": {
"welcome": "Welcome back",
"welcomeNamed": "Welcome back, {{name}}",
"subtitle": "Browse recipes by meal category or explore everything at once.",
"viewRecipes": "View recipes"
},
"recipes": {
"allMealsTitle": "All Meals",
"allMealsSubtitle": "Search and browse every recipe.",
"searchRecipes": "Search recipes...",
"searchByIngredient": "Search by ingredient...",
"noMatchingTitle": "No matching recipes",
"noMatchingIngredient": "No recipes contain that ingredient.",
"noMatchingFilter": "Try adjusting your search or category filter.",
"emptyCategoryTitle": "No {{category}} recipes yet",
"emptyCategoryDescription": "There are currently no recipes in this category.",
"prepTime": "{{count}} min preparation",
"calories": "Calories",
"protein": "Protein",
"fat": "Fat",
"carbs": "Carbs",
"ingredients": "Ingredients",
"preparation": "Preparation",
"noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Manage Meals",
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
"tabManual": "Add manually",
"tabImport": "Import Excel",
"editRecipe": "Edit recipe",
"updatedSuccess": "\"{{name}}\" updated successfully.",
"catalogItem": "Catalog item",
"savedSuccess": "\"{{name}}\" saved successfully.",
"basicInfo": "Basic info",
"recipeName": "Recipe name",
"recipeNamePlaceholder": "Grilled chicken salad",
"prepTimeMin": "Prep time (min)",
"caloriesKcal": "Calories (kcal)",
"proteinG": "Protein (g)",
"fatG": "Fat (g)",
"carbsG": "Carbs (g)",
"preparationSteps": "Preparation steps",
"addStep": "Add step",
"stepPlaceholder": "Describe this step...",
"ingredientName": "Name",
"ingredientGrams": "Grams",
"ingredientUnit": "Unit",
"removeIngredient": "Remove ingredient",
"removeStep": "Remove step",
"saveRecipe": "Save recipe",
"saving": "Saving...",
"importTitle": "Import from Excel",
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps.",
"downloadTemplate": "Download template",
"uploadLabel": "Upload filled .xlsx file",
"importComplete": "Import complete",
"createdCount": "Created: {{count}}",
"skippedCount": "Skipped: {{count}}",
"importRecipes": "Import recipes",
"importing": "Importing...",
"excelFormatTitle": "Excel format",
"excelRecipesSheet": "Recipes: Name, MealCategory, Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplicate recipe names are skipped."
},
"pdf": {
"generate": "Export PDF",
"generating": "Generating...",
"shoppingList": "Shopping List",
"generatedOn": "Generated {{date}} · {{count}} items",
"toTaste": "to taste"
},
"planner": {
"title": "Weekly planner",
"subtitle": "Assign recipes to each day and meal slot.",
"assignRecipe": "Assign recipe",
"removeEntry": "Remove planned meal",
"openShopping": "Open shopping list",
"dailyTarget": "Daily calorie target",
"daySummary": "{{logged}} / {{target}} kcal planned",
"remaining": "{{count}} kcal left",
"slotHint": "Suggested ~{{count}} kcal for this meal",
"suggestionsTitle": "Suggestions for this slot",
"noSuggestions": "Set a daily calorie target to see suggestions.",
"estimatedMeal": "This meal: ~{{count}} kcal",
"selectedRecipe": "Selected: {{name}}",
"clearSelection": "Clear selection",
"exportDay": "Export day plan (PDF)",
"includingSelection": "incl. current pick: {{count}} kcal"
},
"shopping": {
"title": "Shopping list",
"subtitle": "Ingredients aggregated from your weekly meal plan.",
"empty": "No items for this week. Add meals in the planner first."
},
"diet": {
"title": "My Diet",
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
"tabToday": "Today",
"tabHistory": "History",
"tabGoals": "Goals",
"tabReminders": "Reminders",
"today": "Today",
"goToday": "Today",
"quickLog": "Quick log",
"copyYesterday": "Copy yesterday's meals",
"recents": "Recent meals",
"favoriteRecipes": "Favorite recipes",
"favoriteCatalog": "Favorite ingredients",
"historyHint": "Calories consumed over the last 14 days.",
"mealsShort": "meals",
"updateReminder": "Update reminder",
"calories": "Calories",
"water": "Water",
"snack": "Snack",
"logDrink": "Log a drink",
"logMeal": "Mark a meal as eaten",
"searchRecipe": "From a recipe",
"orCustomMeal": "Or enter a custom meal name",
"markEaten": "Mark as eaten",
"mealsToday": "Meals today",
"drinksToday": "Drinks today",
"nothingLogged": "Nothing logged yet.",
"noMacros": "No nutrition info",
"removeEntry": "Remove entry",
"dailyGoals": "Daily goals",
"goalsHint": "Set daily targets — the Today tab shows what is left.",
"calorieGoal": "Calorie goal (kcal)",
"waterGoal": "Water goal (ml)",
"saveGoals": "Save goals",
"addReminder": "Add reminder",
"reminderType": "Type",
"reminderWater": "Drink water",
"reminderMeal": "Meal time",
"reminderCustom": "Custom",
"reminderTitle": "Title",
"reminderTitlePlaceholder": "Drink a glass of water",
"reminderTime": "Time",
"saveReminder": "Save reminder",
"yourReminders": "Your reminders",
"noReminders": "No reminders yet.",
"remaining": "Left",
"portions": "Portions",
"fromCatalog": "From ingredient catalog",
"noCatalogItem": "None",
"grams": "Amount (g)",
"mealPreview": "This meal adds",
"remainingAfter": "After logging you will still have",
"suggestionsTitle": "What to eat next",
"suggestionsHint": "Based on what you still need today.",
"logSuggestion": "Log",
"suggestionReasons": {
"highProtein": "High protein",
"needCarbs": "Good carbs",
"needFat": "Healthy fats",
"needCalories": "Energy boost",
"balanced": "Balanced choice"
}
},
"ingredientCatalog": {
"title": "Ingredient catalog",
"subtitle": "Nutrition values per 100 g, grouped by food category.",
"allCategories": "All",
"searchPlaceholder": "Search ingredients…",
"noResultsTitle": "No ingredients found",
"noResultsDescription": "Try another category or search term.",
"per100gNote": "All values are per 100 g.",
"addIngredient": "Add ingredient",
"editIngredient": "Edit ingredient",
"selectCategory": "Select category",
"nameEn": "Name (English)",
"namePlLabel": "Name (Polish)",
"formInvalid": "Please fill in category and name.",
"deleteConfirm": "Remove this ingredient from the catalog?",
"columns": {
"name": "Ingredient",
"category": "Category",
"calories": "kcal",
"protein": "Protein (g)",
"fat": "Fat (g)",
"carbs": "Carbs (g)",
"fiber": "Fiber (g)"
},
"categories": {
"meat": "Meat",
"poultry": "Poultry",
"fish": "Fish",
"seafood": "Seafood",
"vegetables": "Vegetables",
"fruits": "Fruits",
"dairy": "Dairy",
"eggs": "Eggs",
"grains": "Grains",
"legumes": "Legumes",
"nuts": "Nuts & seeds",
"oils": "Oils & fats"
}
},
"settings": {
"title": "Settings",
"account": "Account",
"preferences": "Preferences"
},
"generators": {
"diet": {
"title": "Diet Generator",
"subtitle": "Build a weekly plan from your recipe catalog and save it to the meal planner.",
"profileTitle": "Your profile",
"heightCm": "Height (cm)",
"weightKg": "Weight (kg)",
"age": "Age",
"sex": "Sex",
"male": "Male",
"female": "Female",
"activity": "Activity level",
"sedentary": "Sedentary",
"light": "Light",
"moderate": "Moderate",
"active": "Active",
"veryActive": "Very active",
"goal": "Goal",
"loseWeight": "Lose weight",
"maintain": "Maintain",
"gainMuscle": "Gain muscle",
"recomp": "Recomposition",
"allergies": "Allergies / notes",
"continue": "Calculate targets",
"macrosTitle": "Daily targets",
"acceptAndGenerate": "Accept & generate 7-day plan",
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
"lock": "Lock",
"unlock": "Unlock",
"viewRecipe": "Details",
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
"commit": "Save plan to calendar",
"done": "Plan saved to your meal planner and daily goals.",
"newPlan": "Create another plan",
"error": "Diet generator failed."
},
"recipe": {
"title": "Recipe Generator",
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
"constraintsTitle": "What should we cook?",
"prompt": "Describe the meal",
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
"targetCalories": "Target calories (kcal)",
"targetCaloriesOptional": "e.g. 500",
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
"maxPrep": "Max prep time (min)",
"exclusions": "Exclude ingredients",
"generate": "Generate recipe",
"recipeCount": "How many recipes to generate",
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
"selectAll": "Select all",
"remove": "Remove",
"saveSelected": "Save selected ({{count}})",
"doneMultiple": "{{count}} recipes saved to your library.",
"generateMore": "Generate more recipes",
"regenIngredients": "Regenerate ingredients",
"regenSteps": "Regenerate steps",
"regenFull": "Regenerate all",
"save": "Save to library",
"done": "Recipe saved.",
"viewRecipe": "View recipe",
"unlinkedWarning": "{{count}} ingredient(s) have grams but no catalog link.",
"error": "Recipe generator failed."
}
},
"notFound": {
"title": "Page not found",
"description": "The page you're looking for doesn't exist.",
"backToDashboard": "Back to dashboard"
}
}

View File

@@ -0,0 +1,387 @@
{
"app": { "name": "DailyMeals" },
"language": {
"label": "Język",
"en": "Angielski",
"pl": "Polski"
},
"appearance": {
"label": "Wygląd",
"forest": "Leśna klasyka",
"forestDesc": "Zielona paleta",
"ocean": "Oceaniczna świeżość",
"oceanDesc": "Niebieska paleta",
"sunset": "Zachód słońca",
"sunsetDesc": "Ciepła bursztynowa paleta",
"berry": "Jagodowy modern",
"berryDesc": "Fioletowa paleta"
},
"nav": {
"dashboard": "Panel",
"allMeals": "Wszystkie posiłki",
"manageMeals": "Zarządzaj posiłkami",
"dietTracker": "Moja dieta",
"planner": "Plan posiłków",
"shopping": "Zakupy",
"ingredientCatalog": "Składniki",
"dietGenerator": "Generator diety",
"recipeGenerator": "Generator przepisów",
"settings": "Ustawienia",
"logout": "Wyloguj"
},
"categories": {
"breakfast": "Śniadanie",
"secondBreakfast": "Drugie śniadanie",
"lunch": "Obiad",
"dinner": "Kolacja",
"unknown": "Nieznana"
},
"common": {
"loading": "Ładowanie...",
"tryAgain": "Spróbuj ponownie",
"back": "Wstecz",
"add": "Dodaj",
"category": "Kategoria",
"all": "Wszystkie",
"search": "Szukaj",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"optional": "opcjonalnie",
"recipeCount_one": "{{count}} przepis",
"recipeCount_few": "{{count}} przepisy",
"recipeCount_many": "{{count}} przepisów",
"recipeCount_other": "{{count}} przepisów"
},
"auth": {
"signInSubtitle": "Zaloguj się, aby planować posiłki",
"email": "E-mail",
"password": "Hasło",
"emailPlaceholder": "ty@example.com",
"signIn": "Zaloguj się",
"signingIn": "Logowanie...",
"noAccount": "Nie masz konta?",
"createOne": "Utwórz je",
"createAccountTitle": "Utwórz konto",
"createAccountSubtitle": "Dołącz do DailyMeals i przeglądaj przepisy",
"displayName": "Nazwa wyświetlana",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Potwierdź hasło",
"passwordHint": "Co najmniej 8 znaków z wielką literą, małą literą i cyfrą.",
"createAccount": "Utwórz konto",
"creatingAccount": "Tworzenie konta...",
"hasAccount": "Masz już konto?"
},
"validation": {
"emailRequired": "E-mail jest wymagany.",
"emailInvalid": "Podaj prawidłowy adres e-mail.",
"passwordRequired": "Hasło jest wymagane.",
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
"passwordUppercase": "Dodaj co najmniej jedną wielką literę.",
"passwordLowercase": "Dodaj co najmniej jedną małą literę.",
"passwordDigit": "Dodaj co najmniej jedną cyfrę.",
"confirmPasswordRequired": "Potwierdź hasło.",
"passwordsMismatch": "Hasła nie są identyczne.",
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
"stepDescriptionRequired": "Opis kroku jest wymagany.",
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
},
"errors": {
"generic": "Coś poszło nie tak.",
"signInFailed": "Nie udało się zalogować.",
"registerFailed": "Nie udało się utworzyć konta.",
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
"loadRecipesFailed": "Nie udało się załadować przepisów.",
"loadRecipeFailed": "Nie udało się załadować przepisu.",
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
"loadDietFailed": "Nie udało się załadować danych diety.",
"loadHistoryFailed": "Nie udało się wczytać historii diety.",
"loadPlannerFailed": "Nie udało się wczytać planu posiłków.",
"loadShoppingFailed": "Nie udało się wczytać listy zakupów.",
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
"pdfNothingToExport": "Brak danych do eksportu.",
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
"saveIngredientFailed": "Nie udało się zapisać składnika."
},
"dashboard": {
"welcome": "Witaj ponownie",
"welcomeNamed": "Witaj ponownie, {{name}}",
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub wszystkie naraz.",
"viewRecipes": "Zobacz przepisy"
},
"recipes": {
"allMealsTitle": "Wszystkie posiłki",
"allMealsSubtitle": "Wyszukuj i przeglądaj wszystkie przepisy.",
"searchRecipes": "Szukaj przepisów...",
"searchByIngredient": "Szukaj po składniku...",
"noMatchingTitle": "Brak pasujących przepisów",
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika.",
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
"emptyCategoryTitle": "Brak przepisów: {{category}}",
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze przepisów.",
"prepTime": "{{count}} min przygotowania",
"calories": "Kalorie",
"protein": "Białko",
"fat": "Tłuszcz",
"carbs": "Węglowodany",
"ingredients": "Składniki",
"preparation": "Przygotowanie",
"noIngredients": "Brak składników.",
"noSteps": "Brak kroków.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Zarządzaj posiłkami",
"subtitle": "Dodawaj przepisy ręcznie lub importuj z arkusza Excel.",
"tabManual": "Dodaj ręcznie",
"tabImport": "Import Excel",
"editRecipe": "Edytuj przepis",
"updatedSuccess": "Zaktualizowano „{{name}}”.",
"catalogItem": "Pozycja katalogu",
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
"basicInfo": "Podstawowe informacje",
"recipeName": "Nazwa przepisu",
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
"prepTimeMin": "Czas przygotowania (min)",
"caloriesKcal": "Kalorie (kcal)",
"proteinG": "Białko (g)",
"fatG": "Tłuszcz (g)",
"carbsG": "Węglowodany (g)",
"preparationSteps": "Kroki przygotowania",
"addStep": "Dodaj krok",
"stepPlaceholder": "Opisz ten krok...",
"ingredientName": "Nazwa",
"ingredientGrams": "Gramy",
"ingredientUnit": "Jednostka",
"removeIngredient": "Usuń składnik",
"removeStep": "Usuń krok",
"saveRecipe": "Zapisz przepis",
"saving": "Zapisywanie...",
"importTitle": "Import z Excela",
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps.",
"downloadTemplate": "Pobierz szablon",
"uploadLabel": "Prześlij wypełniony plik .xlsx",
"importComplete": "Import zakończony",
"createdCount": "Utworzono: {{count}}",
"skippedCount": "Pominięto: {{count}}",
"importRecipes": "Importuj przepisy",
"importing": "Importowanie...",
"excelFormatTitle": "Format Excela",
"excelRecipesSheet": "Recipes: Name, MealCategory, Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
},
"pdf": {
"generate": "Eksportuj PDF",
"generating": "Generowanie...",
"shoppingList": "Lista zakupów",
"generatedOn": "Wygenerowano {{date}} · {{count}} poz.",
"toTaste": "do smaku"
},
"planner": {
"title": "Plan tygodniowy",
"subtitle": "Przypisz przepisy do dni i posiłków.",
"assignRecipe": "Przypisz przepis",
"removeEntry": "Usuń zaplanowany posiłek",
"openShopping": "Otwórz listę zakupów",
"dailyTarget": "Cel kalorii na dzień",
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
"remaining": "Pozostało {{count}} kcal",
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
"suggestionsTitle": "Propozycje na ten posiłek",
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
"selectedRecipe": "Wybrany: {{name}}",
"clearSelection": "Wyczyść wybór",
"exportDay": "Eksportuj plan dnia (PDF)",
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
},
"shopping": {
"title": "Lista zakupów",
"subtitle": "Składniki zebrane z planu posiłków na tydzień.",
"empty": "Brak pozycji w tym tygodniu. Najpierw dodaj posiłki w planerze."
},
"diet": {
"title": "Moja dieta",
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
"tabToday": "Dziś",
"tabHistory": "Historia",
"tabGoals": "Cele",
"tabReminders": "Przypomnienia",
"today": "Dziś",
"goToday": "Dziś",
"quickLog": "Szybkie logowanie",
"copyYesterday": "Kopiuj wczorajsze posiłki",
"recents": "Ostatnie posiłki",
"favoriteRecipes": "Ulubione przepisy",
"favoriteCatalog": "Ulubione składniki",
"historyHint": "Spożyte kalorie z ostatnich 14 dni.",
"mealsShort": "posiłków",
"updateReminder": "Zaktualizuj przypomnienie",
"calories": "Kalorie",
"water": "Woda",
"snack": "Przekąska",
"logDrink": "Dodaj napój",
"logMeal": "Oznacz zjedzony posiłek",
"searchRecipe": "Z przepisu",
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
"markEaten": "Oznacz jako zjedzone",
"mealsToday": "Posiłki dziś",
"drinksToday": "Napoje dziś",
"nothingLogged": "Nic jeszcze nie zalogowano.",
"noMacros": "Brak danych odżywczych",
"removeEntry": "Usuń wpis",
"dailyGoals": "Cele dzienne",
"goalsHint": "Ustaw cele dzienne — zakładka Dziś pokaże, co zostało.",
"calorieGoal": "Cel kalorii (kcal)",
"waterGoal": "Cel wody (ml)",
"saveGoals": "Zapisz cele",
"addReminder": "Dodaj przypomnienie",
"reminderType": "Typ",
"reminderWater": "Pij wodę",
"reminderMeal": "Pora posiłku",
"reminderCustom": "Własne",
"reminderTitle": "Tytuł",
"reminderTitlePlaceholder": "Wypij szklankę wody",
"reminderTime": "Godzina",
"saveReminder": "Zapisz przypomnienie",
"yourReminders": "Twoje przypomnienia",
"noReminders": "Brak przypomnień.",
"remaining": "Pozostało",
"portions": "Porcje",
"fromCatalog": "Z katalogu składników",
"noCatalogItem": "Brak",
"grams": "Ilość (g)",
"mealPreview": "Ten posiłek dodaje",
"remainingAfter": "Po zapisaniu zostanie Ci",
"suggestionsTitle": "Co warto zjeść dalej",
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
"logSuggestion": "Zapisz",
"suggestionReasons": {
"highProtein": "Dużo białka",
"needCarbs": "Dobre węglowodany",
"needFat": "Zdrowe tłuszcze",
"needCalories": "Energia",
"balanced": "Zbilansowany wybór"
}
},
"ingredientCatalog": {
"title": "Katalog składników",
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
"allCategories": "Wszystkie",
"searchPlaceholder": "Szukaj składników…",
"noResultsTitle": "Nie znaleziono składników",
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
"addIngredient": "Dodaj składnik",
"editIngredient": "Edytuj składnik",
"selectCategory": "Wybierz kategorię",
"nameEn": "Nazwa (angielski)",
"namePlLabel": "Nazwa (polski)",
"formInvalid": "Wypełnij kategorię i nazwę.",
"deleteConfirm": "Usunąć ten składnik z katalogu?",
"columns": {
"name": "Składnik",
"category": "Kategoria",
"calories": "kcal",
"protein": "Białko (g)",
"fat": "Tłuszcz (g)",
"carbs": "Węglowodany (g)",
"fiber": "Błonnik (g)"
},
"categories": {
"meat": "Mięso",
"poultry": "Drób",
"fish": "Ryby",
"seafood": "Owoce morza",
"vegetables": "Warzywa",
"fruits": "Owoce",
"dairy": "Nabiał",
"eggs": "Jaja",
"grains": "Zboża",
"legumes": "Rośliny strączkowe",
"nuts": "Orzechy i nasiona",
"oils": "Oleje i tłuszcze"
}
},
"settings": {
"title": "Ustawienia",
"account": "Konto",
"preferences": "Preferencje"
},
"generators": {
"diet": {
"title": "Generator diety",
"subtitle": "Zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
"profileTitle": "Twój profil",
"heightCm": "Wzrost (cm)",
"weightKg": "Waga (kg)",
"age": "Wiek",
"sex": "Płeć",
"male": "Mężczyzna",
"female": "Kobieta",
"activity": "Poziom aktywności",
"sedentary": "Siedzący",
"light": "Lekka",
"moderate": "Umiarkowana",
"active": "Aktywna",
"veryActive": "Bardzo aktywna",
"goal": "Cel",
"loseWeight": "Redukcja wagi",
"maintain": "Utrzymanie",
"gainMuscle": "Budowa mięśni",
"recomp": "Rekompozycja",
"allergies": "Alergie / uwagi",
"continue": "Oblicz cele",
"macrosTitle": "Dzienne cele",
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
"lock": "Zablokuj",
"unlock": "Odblokuj",
"viewRecipe": "Szczegóły",
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
"commit": "Zapisz plan w kalendarzu",
"done": "Plan zapisany w planerze i celach dziennych.",
"newPlan": "Utwórz kolejny plan",
"error": "Generator diety nie powiódł się."
},
"recipe": {
"title": "Generator przepisów",
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
"constraintsTitle": "Co ugotować?",
"prompt": "Opisz posiłek",
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
"targetCalories": "Docelowa kaloryczność (kcal)",
"targetCaloriesOptional": "np. 500",
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
"maxPrep": "Maks. czas przygotowania (min)",
"exclusions": "Wyklucz składniki",
"generate": "Generuj przepis",
"recipeCount": "Ile przepisów wygenerować",
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
"selectAll": "Zaznacz wszystkie",
"remove": "Usuń",
"saveSelected": "Zapisz zaznaczone ({{count}})",
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
"generateMore": "Generuj kolejne przepisy",
"regenIngredients": "Generuj składniki ponownie",
"regenSteps": "Generuj kroki ponownie",
"regenFull": "Generuj całość ponownie",
"save": "Zapisz w bibliotece",
"done": "Przepis zapisany.",
"viewRecipe": "Zobacz przepis",
"unlinkedWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem.",
"error": "Generator przepisów nie powiódł się."
}
},
"notFound": {
"title": "Strona nie znaleziona",
"description": "Szukana strona nie istnieje.",
"backToDashboard": "Wróć do panelu"
}
}

View File

@@ -0,0 +1,76 @@
export type AppearanceId = 'forest' | 'ocean' | 'sunset' | 'berry';
export interface ThemeColors {
brand: string;
brandMuted: string;
brandLight: string;
background: string;
surface: string;
text: string;
textMuted: string;
border: string;
danger: string;
}
const PALETTES: Record<AppearanceId, ThemeColors> = {
forest: {
brand: '#16774c',
brandMuted: '#1e9a61',
brandLight: '#e8f5ef',
background: '#f6f8f7',
surface: '#ffffff',
text: '#0f172a',
textMuted: '#64748b',
border: '#e2e8f0',
danger: '#dc2626',
},
ocean: {
brand: '#2563eb',
brandMuted: '#3b82f6',
brandLight: '#eff6ff',
background: '#f8fafc',
surface: '#ffffff',
text: '#0f172a',
textMuted: '#64748b',
border: '#e2e8f0',
danger: '#dc2626',
},
sunset: {
brand: '#d97706',
brandMuted: '#f59e0b',
brandLight: '#fffbeb',
background: '#fffaf5',
surface: '#ffffff',
text: '#1c1917',
textMuted: '#78716c',
border: '#e7e5e4',
danger: '#dc2626',
},
berry: {
brand: '#7c3aed',
brandMuted: '#8b5cf6',
brandLight: '#f5f3ff',
background: '#faf8ff',
surface: '#ffffff',
text: '#1e1b4b',
textMuted: '#6b7280',
border: '#e5e7eb',
danger: '#dc2626',
},
};
export const APPEARANCE_OPTIONS: Array<{
id: AppearanceId;
labelKey: string;
descriptionKey: string;
swatch: string;
}> = [
{ id: 'forest', labelKey: 'appearance.forest', descriptionKey: 'appearance.forestDesc', swatch: '#16774c' },
{ id: 'ocean', labelKey: 'appearance.ocean', descriptionKey: 'appearance.oceanDesc', swatch: '#2563eb' },
{ id: 'sunset', labelKey: 'appearance.sunset', descriptionKey: 'appearance.sunsetDesc', swatch: '#d97706' },
{ id: 'berry', labelKey: 'appearance.berry', descriptionKey: 'appearance.berryDesc', swatch: '#7c3aed' },
];
export function getThemeColors(id: AppearanceId): ThemeColors {
return PALETTES[id];
}

View File

@@ -0,0 +1,23 @@
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
tokenType: string;
}
export interface UserInfo {
id: string;
userName: string | null;
email: string | null;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
userName?: string;
}

View File

@@ -0,0 +1,219 @@
export interface UserDailyGoals {
calorieGoal?: number | null;
proteinGoalG?: number | null;
fatGoalG?: number | null;
carbsGoalG?: number | null;
waterGoalMl?: number | null;
}
export interface MacroRemaining {
goal?: number | null;
consumed: number;
remaining?: number | null;
progressPercent?: number | null;
}
export interface DecimalMacroRemaining {
goal?: number | null;
consumed: number;
remaining?: number | null;
progressPercent?: number | null;
}
export interface MealConsumption {
id: number;
recipeId?: number | null;
mealCategory: number;
logDate: string;
consumedAt: string;
name: string;
portions: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
notes?: string | null;
}
export interface DrinkCatalogItem {
id: number;
code: string;
name: string;
defaultVolumeMl: number;
caloriesPer100Ml?: number | null;
iconEmoji?: string | null;
}
export interface DrinkConsumption {
id: number;
drinkCatalogId?: number | null;
logDate: string;
consumedAt: string;
name: string;
volumeMl: number;
calories?: number | null;
}
export interface DailySummary {
date: string;
goals?: UserDailyGoals | null;
calories: MacroRemaining;
proteinG: DecimalMacroRemaining;
fatG: DecimalMacroRemaining;
carbsG: DecimalMacroRemaining;
waterMl: MacroRemaining;
meals: MealConsumption[];
drinks: DrinkConsumption[];
}
export interface LogMealInput {
recipeId?: number;
catalogItemId?: number;
grams?: number;
name?: string;
mealCategory: number;
logDate?: string;
portions?: number;
calories?: number;
proteinG?: number;
fatG?: number;
carbsG?: number;
notes?: string;
}
export interface LogDrinkInput {
drinkCatalogId?: number;
name?: string;
volumeMl: number;
logDate?: string;
calories?: number;
}
export interface DietReminder {
id: number;
reminderType: number;
title: string;
message?: string | null;
timeOfDay: string;
daysOfWeekMask: number;
mealCategory?: number | null;
isEnabled: boolean;
}
export interface CreateDietReminderInput {
reminderType: number;
title: string;
message?: string;
timeOfDay: string;
daysOfWeekMask?: number;
mealCategory?: number;
isEnabled?: boolean;
}
export type UpdateDietReminderInput = CreateDietReminderInput;
export interface DueReminder {
id: number;
reminderType: number;
title: string;
message?: string | null;
timeOfDay: string;
mealCategory?: number | null;
}
export interface HistoryDay {
date: string;
calories: number;
proteinG: number;
fatG: number;
carbsG: number;
waterMl: number;
mealCount: number;
}
export interface RecentMeal {
recipeId?: number | null;
catalogItemId?: number | null;
name: string;
mealCategory: number;
portions: number;
grams?: number | null;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
lastLoggedAt: string;
}
export interface FavoriteRecipe {
recipeId: number;
name: string;
mealCategory: number;
calories: number | null;
}
export interface FavoriteCatalogItem {
catalogItemId: number;
name: string;
namePl?: string | null;
categoryCode: string;
}
export interface CopyMealsResult {
sourceDate: string;
targetDate: string;
copiedCount: number;
}
export enum DietReminderType {
Water = 0,
Meal = 1,
Snack = 2,
Custom = 3,
}
export const CONSUMPTION_CATEGORIES = [
{ value: 0, labelKey: 'categories.breakfast' },
{ value: 1, labelKey: 'categories.secondBreakfast' },
{ value: 2, labelKey: 'categories.lunch' },
{ value: 3, labelKey: 'categories.dinner' },
{ value: 4, labelKey: 'diet.snack' },
] as const;
export const REMINDER_DAYS_MASK_ALL = 127;
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export interface MealMacroSnapshot {
name: string;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}
export interface RemainingAfterMeal {
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
}
export interface MealPreview {
meal: MealMacroSnapshot;
remainingAfter: RemainingAfterMeal;
}
export interface DietSuggestion {
catalogItemId: number;
name: string;
namePl?: string | null;
suggestedGrams: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
reasonKey: string;
}

View File

@@ -0,0 +1,149 @@
export interface DietUserProfile {
heightCm: number;
weightKg: number;
age: number;
sex: 'male' | 'female';
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active';
goal: 'lose_weight' | 'maintain' | 'gain_muscle' | 'recomp';
allergiesNotes?: string | null;
dislikedFoods?: string | null;
mealsPerDayMask: number;
}
export interface MacroProposal {
calories: number;
proteinG: number;
fatG: number;
carbsG: number;
rationale?: string | null;
}
export interface DietGeneratorDraftMealIngredient {
id: number;
name: string;
amountGrams?: number | null;
unit?: string | null;
sortOrder: number;
sourceIngredientId?: number | null;
}
export interface DietGeneratorDraftMealStep {
id: number;
stepNumber: number;
description: string;
}
export interface DietGeneratorDraftMeal {
id: number;
planDate: string;
mealCategory: number;
recipeId: number;
recipeName: string;
portions: number;
calories?: number | null;
proteinG?: number | null;
fatG?: number | null;
carbsG?: number | null;
isLocked: boolean;
prepTimeMinutes?: number | null;
ingredients: DietGeneratorDraftMealIngredient[];
steps: DietGeneratorDraftMealStep[];
}
export interface DietGeneratorDaySummary {
planDate: string;
totalCalories: number;
targetCalories: number;
withinTolerance: boolean;
}
export interface DietGeneratorSession {
id: number;
status: string;
proposedMacros?: MacroProposal | null;
planStartDate?: string | null;
planDayCount: number;
calorieToleranceKcal: number;
draftMeals: DietGeneratorDraftMeal[];
daySummaries: DietGeneratorDaySummary[];
}
export interface CommitDietPlanResult {
sessionId: number;
planStartDate: string;
planEndDate: string;
mealsWritten: number;
}
export interface RecipeGeneratorConstraints {
prompt: string;
mealCategory: number;
targetCalories?: number | null;
calorieToleranceKcal?: number;
maxPrepTimeMinutes?: number | null;
cuisineStyle?: string | null;
dietStyle?: string | null;
exclusions?: string | null;
}
export interface GeneratedIngredient {
name: string;
amountGrams?: number | null;
unit?: string | null;
catalogItemId?: number | null;
catalogName?: string | null;
isLinked: boolean;
}
export interface GeneratedStep {
stepNumber: number;
description: string;
}
export interface GeneratedRecipeDraft {
draftId: string;
name: string;
mealCategory: number;
prepTimeMinutes?: number | null;
calories?: number | null;
protein?: number | null;
fat?: number | null;
carbs?: number | null;
ingredients: GeneratedIngredient[];
steps: GeneratedStep[];
unlinkedIngredientCount: number;
}
export interface RecipeGeneratorSession {
id: number;
status: string;
constraints: RecipeGeneratorConstraints;
draft?: GeneratedRecipeDraft | null;
drafts: GeneratedRecipeDraft[];
savedRecipeId?: number | null;
generationVersion: number;
}
export interface CommitRecipeResult {
sessionId: number;
recipeId: number;
recipeName: string;
draftId: string;
}
export interface CommitRecipesResult {
sessionId: number;
saved: CommitRecipeResult[];
}
export const DEFAULT_DIET_PROFILE: DietUserProfile = {
heightCm: 175,
weightKg: 75,
age: 30,
sex: 'male',
activityLevel: 'moderate',
goal: 'maintain',
mealsPerDayMask: 15,
allergiesNotes: '',
dislikedFoods: '',
};

View File

@@ -0,0 +1,33 @@
export interface IngredientCategory {
id: number;
code: string;
name: string;
itemCount: number;
}
export interface IngredientNutritionItem {
id: number;
categoryId: number;
categoryCode: string;
categoryName: string;
name: string;
namePl?: string | null;
caloriesPer100G: number;
proteinGPer100G: number;
fatGPer100G: number;
carbsGPer100G: number;
fiberGPer100G: number | null;
}
export interface UpsertIngredientNutritionItemInput {
categoryId: number;
name: string;
namePl?: string | null;
caloriesPer100G: number;
proteinGPer100G: number;
fatGPer100G: number;
carbsGPer100G: number;
fiberGPer100G?: number | null;
sortOrder?: number;
isActive?: boolean;
}

View File

@@ -0,0 +1,24 @@
export interface MealPlanEntry {
id: number;
planDate: string;
mealCategory: number;
recipeId: number;
recipeName: string;
portions: number;
calories: number | null;
}
export interface UpsertMealPlanEntryInput {
planDate: string;
mealCategory: number;
recipeId: number;
portions?: number;
}
export interface ShoppingListItem {
name: string;
unit: string | null;
totalGrams: number | null;
totalAmount: number | null;
breakdown: string[];
}

View File

@@ -0,0 +1,6 @@
export interface RecipeImportResult {
createdCount: number;
skippedCount: number;
errors: string[];
createdRecipeIds: number[];
}

View File

@@ -0,0 +1,66 @@
export enum MealCategory {
Breakfast = 0,
SecondBreakfast = 1,
Lunch = 2,
Dinner = 3,
}
export interface RecipeListItem {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
prepTimeMinutes: number | null;
}
export interface Ingredient {
id: number;
name: string;
amountGrams: number | null;
unit: string | null;
catalogItemId?: number | null;
sortOrder: number;
}
export interface RecipeStep {
id: number;
stepNumber: number;
description: string;
}
export interface RecipeDetail {
id: number;
name: string;
mealCategory: MealCategory;
calories: number | null;
protein: number | null;
fat: number | null;
carbs: number | null;
prepTimeMinutes: number | null;
ingredients: Ingredient[];
steps: RecipeStep[];
}
export interface CategoryCount {
category: MealCategory;
name: string;
count: number;
}
export interface CreateRecipeInput {
name: string;
mealCategory: MealCategory;
calories: number | null;
protein: number | null;
fat: number | null;
carbs: number | null;
prepTimeMinutes: number | null;
ingredients: Array<{
name: string;
amountGrams: number | null;
unit: string;
catalogItemId?: number | null;
sortOrder: number;
}>;
steps: Array<{ stepNumber: number; description: string }>;
}

View File

@@ -0,0 +1,19 @@
export function extractApiError(error: unknown, fallback = 'Something went wrong.'): string {
if (error instanceof ApiError) {
return error.message || fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ApiError';
}
}

View File

@@ -0,0 +1,18 @@
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export function getApiBaseUrl(): string {
const configured = Constants.expoConfig?.extra?.apiUrl as string | undefined;
if (configured && !configured.includes('localhost')) {
return configured.replace(/\/$/, '');
}
if (__DEV__) {
if (Platform.OS === 'android') {
return 'http://10.0.2.2:5000/api';
}
return 'http://localhost:5000/api';
}
return (configured ?? 'https://localhost/api').replace(/\/$/, '');
}

View File

@@ -0,0 +1,39 @@
import { todayIso } from '@/src/types/diet';
export function addDays(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T12:00:00`);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
export function formatDisplayDate(isoDate: string, locale: string): string {
const d = new Date(`${isoDate}T12:00:00`);
return d.toLocaleDateString(locale.startsWith('pl') ? 'pl-PL' : 'en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
export function weekRange(anchorDate = todayIso()): { from: string; to: string } {
const d = new Date(`${anchorDate}T12:00:00`);
const day = d.getDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return {
from: monday.toISOString().slice(0, 10),
to: sunday.toISOString().slice(0, 10),
};
}
export function weekDays(from: string): string[] {
return Array.from({ length: 7 }, (_, i) => addDays(from, i));
}
export function historyRange(days = 14): { from: string; to: string } {
const to = todayIso();
return { from: addDays(to, -(days - 1)), to };
}

View File

@@ -0,0 +1,82 @@
import type { DailySummary, DecimalMacroRemaining, MacroRemaining } from '@/src/types/diet';
const EMPTY_INT_MACRO: MacroRemaining = {
consumed: 0,
goal: null,
remaining: null,
progressPercent: null,
};
const EMPTY_DECIMAL_MACRO: DecimalMacroRemaining = {
consumed: 0,
goal: null,
remaining: null,
progressPercent: null,
};
function pick<T>(raw: Record<string, unknown>, camel: string, pascal: string): T | undefined {
return (raw[camel] ?? raw[pascal]) as T | undefined;
}
function normalizeIntMacro(raw: unknown): MacroRemaining {
if (!raw || typeof raw !== 'object') return { ...EMPTY_INT_MACRO };
const obj = raw as Record<string, unknown>;
return {
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
| number
| null,
};
}
function normalizeDecimalMacro(raw: unknown): DecimalMacroRemaining {
if (!raw || typeof raw !== 'object') return { ...EMPTY_DECIMAL_MACRO };
const obj = raw as Record<string, unknown>;
return {
goal: (pick<number | null>(obj, 'goal', 'Goal') ?? null) as number | null,
consumed: Number(pick<number>(obj, 'consumed', 'Consumed') ?? 0),
remaining: (pick<number | null>(obj, 'remaining', 'Remaining') ?? null) as number | null,
progressPercent: (pick<number | null>(obj, 'progressPercent', 'ProgressPercent') ?? null) as
| number
| null,
};
}
export function normalizeDailySummary(raw: unknown): DailySummary {
if (!raw || typeof raw !== 'object') {
return {
date: '',
goals: null,
calories: { ...EMPTY_INT_MACRO },
proteinG: { ...EMPTY_DECIMAL_MACRO },
fatG: { ...EMPTY_DECIMAL_MACRO },
carbsG: { ...EMPTY_DECIMAL_MACRO },
waterMl: { ...EMPTY_INT_MACRO },
meals: [],
drinks: [],
};
}
const obj = raw as Record<string, unknown>;
const meals = pick<unknown[]>(obj, 'meals', 'Meals');
const drinks = pick<unknown[]>(obj, 'drinks', 'Drinks');
return {
date: String(pick<string>(obj, 'date', 'Date') ?? ''),
goals: (pick(obj, 'goals', 'Goals') as DailySummary['goals']) ?? null,
calories: normalizeIntMacro(pick(obj, 'calories', 'Calories')),
proteinG: normalizeDecimalMacro(pick(obj, 'proteinG', 'ProteinG')),
fatG: normalizeDecimalMacro(pick(obj, 'fatG', 'FatG')),
carbsG: normalizeDecimalMacro(pick(obj, 'carbsG', 'CarbsG')),
waterMl: normalizeIntMacro(pick(obj, 'waterMl', 'WaterMl')),
meals: Array.isArray(meals) ? (meals as DailySummary['meals']) : [],
drinks: Array.isArray(drinks) ? (drinks as DailySummary['drinks']) : [],
};
}
export function formatReminderTime(value: string | null | undefined): string {
if (!value) return '';
return value.length >= 5 ? value.slice(0, 5) : value;
}

View File

@@ -0,0 +1,11 @@
import type { IngredientNutritionItem } from '@/src/types/ingredient-catalog';
export function ingredientDisplayName(
item: Pick<IngredientNutritionItem, 'name' | 'namePl'>,
language: string,
): string {
if (language.startsWith('pl') && item.namePl) {
return item.namePl;
}
return item.name;
}

View File

@@ -0,0 +1,98 @@
import type { MealPlanEntry } from '@/src/types/meal-plan';
import { MealCategory, type RecipeListItem } from '@/src/types/recipe';
export const PLANNER_MEAL_CATEGORIES = [
MealCategory.Breakfast,
MealCategory.SecondBreakfast,
MealCategory.Lunch,
MealCategory.Dinner,
] as const;
export interface DayMealPlanning {
dailyTarget: number;
logged: number;
remaining: number;
slotTarget: number;
openSlots: number;
pendingCalories: number;
}
export interface PendingMealSelection {
category: number;
calories: number;
}
export function entryCaloriesTotal(entry: MealPlanEntry): number {
if (entry.calories == null) return 0;
return Math.round(entry.calories * entry.portions);
}
export function computeDayMealPlanning(
date: string,
category: number,
entries: MealPlanEntry[],
dailyTarget: number,
pending?: PendingMealSelection | null,
): DayMealPlanning {
const dayEntries = entries.filter((e) => e.planDate === date);
const categoriesWithSaved = new Set(dayEntries.map((e) => e.mealCategory));
const existingInSlot = dayEntries.find((e) => e.mealCategory === category);
let logged = dayEntries.reduce((sum, e) => sum + entryCaloriesTotal(e), 0);
const pendingCalories =
pending && pending.category === category && pending.calories > 0 ? pending.calories : 0;
if (pendingCalories > 0) {
if (existingInSlot) {
logged = logged - entryCaloriesTotal(existingInSlot) + pendingCalories;
} else {
logged += pendingCalories;
}
}
const remaining = dailyTarget - logged;
const emptyCategories = PLANNER_MEAL_CATEGORIES.filter((cat) => !categoriesWithSaved.has(cat));
const currentSlotSaved = categoriesWithSaved.has(category as MealCategory);
let unfilled = [...emptyCategories];
if (pendingCalories > 0 && !currentSlotSaved) {
unfilled = unfilled.filter((cat) => cat !== category);
}
let openSlots: number;
if (currentSlotSaved) {
openSlots = Math.max(1, unfilled.length + 1);
} else if (pendingCalories > 0) {
openSlots = Math.max(1, unfilled.length);
} else if (unfilled.includes(category as MealCategory)) {
openSlots = Math.max(1, unfilled.length);
} else {
openSlots = Math.max(1, unfilled.length + 1);
}
const slotTarget = dailyTarget > 0 ? Math.round(Math.max(0, remaining) / openSlots) : 0;
return { dailyTarget, logged, remaining, slotTarget, openSlots, pendingCalories };
}
export function rankRecipeSuggestions(
recipes: RecipeListItem[],
category: number,
slotTarget: number,
limit = 6,
): { recipe: RecipeListItem; diff: number }[] {
if (slotTarget <= 0) return [];
return recipes
.filter((r) => r.mealCategory === category && r.calories != null && r.calories > 0)
.map((recipe) => ({ recipe, diff: Math.abs(recipe.calories! - slotTarget) }))
.sort((a, b) => a.diff - b.diff || a.recipe.name.localeCompare(b.recipe.name))
.slice(0, limit);
}
export function estimateEntryCalories(recipe: RecipeListItem | null, portions: number): number | null {
if (!recipe?.calories) return null;
return Math.round(recipe.calories * portions);
}

View File

@@ -0,0 +1,55 @@
import type { ShoppingListItem } from '@/src/types/meal-plan';
function formatNumber(value: number): string {
return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/\.?0+$/, '');
}
export function formatShoppingQuantity(item: ShoppingListItem, toTasteLabel: string): string {
if (item.unit && !item.totalGrams && !item.totalAmount) {
return item.unit;
}
if (item.totalGrams != null) {
return `${formatNumber(item.totalGrams)} g`;
}
if (item.totalAmount != null && item.unit) {
return `${formatNumber(item.totalAmount)} ${item.unit}`;
}
return toTasteLabel;
}
export function buildShoppingListHtml(
items: ShoppingListItem[],
title: string,
generatedLabel: string,
toTasteLabel: string,
): string {
const rows = items
.map((item) => {
const qty = formatShoppingQuantity(item, toTasteLabel);
const breakdown =
item.breakdown.length > 0
? `<div style="font-size:11px;color:#64748b;margin-top:2px">${item.breakdown.join(' · ')}</div>`
: '';
return `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0">${item.name}${breakdown}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;white-space:nowrap">${qty}</td>
</tr>`;
})
.join('');
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:system-ui,sans-serif;padding:32px;color:#0f172a">
<h1 style="font-size:24px;margin:0 0 4px">${title}</h1>
<p style="color:#64748b;font-size:13px;margin:0 0 24px">${generatedLabel}</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead>
<tr style="background:#f1f5f9">
<th style="padding:8px 12px;text-align:left">Item</th>
<th style="padding:8px 12px;text-align:right">Qty</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</body></html>`;
}

View File

@@ -0,0 +1,39 @@
import { MealCategory } from '@/src/types/recipe';
import type { TFunction } from 'i18next';
export function categoryLabel(category: MealCategory, t: TFunction): string {
switch (category) {
case MealCategory.Breakfast:
return t('categories.breakfast');
case MealCategory.SecondBreakfast:
return t('categories.secondBreakfast');
case MealCategory.Lunch:
return t('categories.lunch');
case MealCategory.Dinner:
return t('categories.dinner');
default:
return t('categories.unknown');
}
}
export const ALL_CATEGORIES = [
MealCategory.Breakfast,
MealCategory.SecondBreakfast,
MealCategory.Lunch,
MealCategory.Dinner,
];
export function parseCategoryParam(value: string): MealCategory | null {
const num = Number(value);
if (Number.isInteger(num) && num >= 0 && num <= 3) {
return num as MealCategory;
}
return null;
}
export function parseOptionalNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const num = Number(trimmed);
return Number.isFinite(num) ? num : null;
}

View File

@@ -0,0 +1,53 @@
export type SortDirection = 'asc' | 'desc';
export function nextSort<T extends string>(
current: { column: T; direction: SortDirection } | null,
column: T,
): { column: T; direction: SortDirection } {
if (current?.column === column) {
return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
}
return { column, direction: 'asc' };
}
export function sortIndicator(
current: { column: string; direction: SortDirection } | null,
column: string,
): string {
if (current?.column !== column) return '';
return current.direction === 'asc' ? ' ↑' : ' ↓';
}
export function compareText(a: string, b: string, direction: SortDirection): number {
const result = a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
return direction === 'asc' ? result : -result;
}
export function compareNumber(
a: number | null | undefined,
b: number | null | undefined,
direction: SortDirection,
): number {
const aMissing = a == null || Number.isNaN(a);
const bMissing = b == null || Number.isNaN(b);
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
const result = a - b;
return direction === 'asc' ? result : -result;
}
export function sortBy<T>(
items: readonly T[],
direction: SortDirection,
getValue: (item: T) => string | number | null | undefined,
): T[] {
return [...items].sort((left, right) => {
const a = getValue(left);
const b = getValue(right);
if (typeof a === 'number' || typeof b === 'number') {
return compareNumber(typeof a === 'number' ? a : null, typeof b === 'number' ? b : null, direction);
}
return compareText(String(a ?? ''), String(b ?? ''), direction);
});
}

View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}