* Extended functionalities
* Added different UIs
This commit is contained in:
5
meal-plan-frontend-mobile/.claude/settings.json
Normal file
5
meal-plan-frontend-mobile/.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
41
meal-plan-frontend-mobile/.gitignore
vendored
Normal file
41
meal-plan-frontend-mobile/.gitignore
vendored
Normal 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
|
||||
1
meal-plan-frontend-mobile/.vscode/extensions.json
vendored
Normal file
1
meal-plan-frontend-mobile/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
meal-plan-frontend-mobile/.vscode/settings.json
vendored
Normal file
7
meal-plan-frontend-mobile/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
3
meal-plan-frontend-mobile/AGENTS.md
Normal file
3
meal-plan-frontend-mobile/AGENTS.md
Normal 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.
|
||||
1
meal-plan-frontend-mobile/CLAUDE.md
Normal file
1
meal-plan-frontend-mobile/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
21
meal-plan-frontend-mobile/LICENSE
Normal file
21
meal-plan-frontend-mobile/LICENSE
Normal 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.
|
||||
87
meal-plan-frontend-mobile/README.md
Normal file
87
meal-plan-frontend-mobile/README.md
Normal 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.
|
||||
41
meal-plan-frontend-mobile/app.config.ts
Normal file
41
meal-plan-frontend-mobile/app.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
43
meal-plan-frontend-mobile/app.json
Normal file
43
meal-plan-frontend-mobile/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
meal-plan-frontend-mobile/app/(auth)/_layout.tsx
Normal file
10
meal-plan-frontend-mobile/app/(auth)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
meal-plan-frontend-mobile/app/(auth)/login.tsx
Normal file
117
meal-plan-frontend-mobile/app/(auth)/login.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
145
meal-plan-frontend-mobile/app/(auth)/register.tsx
Normal file
145
meal-plan-frontend-mobile/app/(auth)/register.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
80
meal-plan-frontend-mobile/app/(main)/(tabs)/_layout.tsx
Normal file
80
meal-plan-frontend-mobile/app/(main)/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
945
meal-plan-frontend-mobile/app/(main)/(tabs)/diet.tsx
Normal file
945
meal-plan-frontend-mobile/app/(main)/(tabs)/diet.tsx
Normal 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 },
|
||||
});
|
||||
198
meal-plan-frontend-mobile/app/(main)/(tabs)/index.tsx
Normal file
198
meal-plan-frontend-mobile/app/(main)/(tabs)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal file
362
meal-plan-frontend-mobile/app/(main)/(tabs)/manage.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
163
meal-plan-frontend-mobile/app/(main)/(tabs)/meals.tsx
Normal file
163
meal-plan-frontend-mobile/app/(main)/(tabs)/meals.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
348
meal-plan-frontend-mobile/app/(main)/(tabs)/planner.tsx
Normal file
348
meal-plan-frontend-mobile/app/(main)/(tabs)/planner.tsx
Normal 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 },
|
||||
});
|
||||
154
meal-plan-frontend-mobile/app/(main)/(tabs)/settings.tsx
Normal file
154
meal-plan-frontend-mobile/app/(main)/(tabs)/settings.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
126
meal-plan-frontend-mobile/app/(main)/(tabs)/shopping.tsx
Normal file
126
meal-plan-frontend-mobile/app/(main)/(tabs)/shopping.tsx
Normal 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' },
|
||||
});
|
||||
38
meal-plan-frontend-mobile/app/(main)/_layout.tsx
Normal file
38
meal-plan-frontend-mobile/app/(main)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
meal-plan-frontend-mobile/app/(main)/category/[category].tsx
Normal file
152
meal-plan-frontend-mobile/app/(main)/category/[category].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal file
479
meal-plan-frontend-mobile/app/(main)/diet-generator.tsx
Normal 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 },
|
||||
});
|
||||
434
meal-plan-frontend-mobile/app/(main)/ingredients.tsx
Normal file
434
meal-plan-frontend-mobile/app/(main)/ingredients.tsx
Normal 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' },
|
||||
});
|
||||
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal file
376
meal-plan-frontend-mobile/app/(main)/recipe-generator.tsx
Normal 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 },
|
||||
});
|
||||
205
meal-plan-frontend-mobile/app/(main)/recipe/[id].tsx
Normal file
205
meal-plan-frontend-mobile/app/(main)/recipe/[id].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal file
202
meal-plan-frontend-mobile/app/(main)/recipe/edit/[id].tsx
Normal 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 },
|
||||
});
|
||||
39
meal-plan-frontend-mobile/app/+html.tsx
Normal file
39
meal-plan-frontend-mobile/app/+html.tsx
Normal 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;
|
||||
}
|
||||
}`;
|
||||
47
meal-plan-frontend-mobile/app/+not-found.tsx
Normal file
47
meal-plan-frontend-mobile/app/+not-found.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
70
meal-plan-frontend-mobile/app/_layout.tsx
Normal file
70
meal-plan-frontend-mobile/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
meal-plan-frontend-mobile/app/index.tsx
Normal file
17
meal-plan-frontend-mobile/app/index.tsx
Normal 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" />;
|
||||
}
|
||||
BIN
meal-plan-frontend-mobile/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
meal-plan-frontend-mobile/assets/fonts/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
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 |
BIN
meal-plan-frontend-mobile/assets/images/favicon.png
Normal file
BIN
meal-plan-frontend-mobile/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
meal-plan-frontend-mobile/assets/images/icon.png
Normal file
BIN
meal-plan-frontend-mobile/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
meal-plan-frontend-mobile/assets/images/splash-icon.png
Normal file
BIN
meal-plan-frontend-mobile/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
76
meal-plan-frontend-mobile/components/EditScreenInfo.tsx
Normal file
76
meal-plan-frontend-mobile/components/EditScreenInfo.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
22
meal-plan-frontend-mobile/components/ExternalLink.tsx
Normal file
22
meal-plan-frontend-mobile/components/ExternalLink.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
meal-plan-frontend-mobile/components/StyledText.tsx
Normal file
5
meal-plan-frontend-mobile/components/StyledText.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Text, TextProps } from './Themed';
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||
}
|
||||
45
meal-plan-frontend-mobile/components/Themed.tsx
Normal file
45
meal-plan-frontend-mobile/components/Themed.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
meal-plan-frontend-mobile/components/useColorScheme.ts
Normal file
6
meal-plan-frontend-mobile/components/useColorScheme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useColorScheme as useColorSchemeCore } from 'react-native';
|
||||
|
||||
export const useColorScheme = () => {
|
||||
const coreScheme = useColorSchemeCore();
|
||||
return coreScheme === 'unspecified' ? 'light' : coreScheme;
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
19
meal-plan-frontend-mobile/constants/Colors.ts
Normal file
19
meal-plan-frontend-mobile/constants/Colors.ts
Normal 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
7875
meal-plan-frontend-mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
meal-plan-frontend-mobile/package.json
Normal file
46
meal-plan-frontend-mobile/package.json
Normal 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
|
||||
}
|
||||
40
meal-plan-frontend-mobile/src/api/auth.api.ts
Normal file
40
meal-plan-frontend-mobile/src/api/auth.api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
135
meal-plan-frontend-mobile/src/api/client.ts
Normal file
135
meal-plan-frontend-mobile/src/api/client.ts
Normal 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;
|
||||
}
|
||||
89
meal-plan-frontend-mobile/src/api/diet-generator.api.ts
Normal file
89
meal-plan-frontend-mobile/src/api/diet-generator.api.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
107
meal-plan-frontend-mobile/src/api/diet.api.ts
Normal file
107
meal-plan-frontend-mobile/src/api/diet.api.ts
Normal 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');
|
||||
}
|
||||
44
meal-plan-frontend-mobile/src/api/ingredient-catalog.api.ts
Normal file
44
meal-plan-frontend-mobile/src/api/ingredient-catalog.api.ts
Normal 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' });
|
||||
}
|
||||
22
meal-plan-frontend-mobile/src/api/meal-plan.api.ts
Normal file
22
meal-plan-frontend-mobile/src/api/meal-plan.api.ts
Normal 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}`);
|
||||
}
|
||||
59
meal-plan-frontend-mobile/src/api/recipe-generator.api.ts
Normal file
59
meal-plan-frontend-mobile/src/api/recipe-generator.api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
47
meal-plan-frontend-mobile/src/api/recipe-management.api.ts
Normal file
47
meal-plan-frontend-mobile/src/api/recipe-management.api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
52
meal-plan-frontend-mobile/src/api/recipes.api.ts
Normal file
52
meal-plan-frontend-mobile/src/api/recipes.api.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
90
meal-plan-frontend-mobile/src/components/Button.tsx
Normal file
90
meal-plan-frontend-mobile/src/components/Button.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
81
meal-plan-frontend-mobile/src/components/DateNavigator.tsx
Normal file
81
meal-plan-frontend-mobile/src/components/DateNavigator.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
29
meal-plan-frontend-mobile/src/components/ErrorView.tsx
Normal file
29
meal-plan-frontend-mobile/src/components/ErrorView.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
110
meal-plan-frontend-mobile/src/components/ExcelImportPanel.tsx
Normal file
110
meal-plan-frontend-mobile/src/components/ExcelImportPanel.tsx
Normal 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 },
|
||||
});
|
||||
51
meal-plan-frontend-mobile/src/components/Input.tsx
Normal file
51
meal-plan-frontend-mobile/src/components/Input.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
80
meal-plan-frontend-mobile/src/components/RecipeCard.tsx
Normal file
80
meal-plan-frontend-mobile/src/components/RecipeCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useReminderNotifications } from '@/src/hooks/useReminderNotifications';
|
||||
|
||||
export function ReminderNotificationBridge() {
|
||||
useReminderNotifications();
|
||||
return null;
|
||||
}
|
||||
57
meal-plan-frontend-mobile/src/components/Screen.tsx
Normal file
57
meal-plan-frontend-mobile/src/components/Screen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
49
meal-plan-frontend-mobile/src/components/SimpleBarChart.tsx
Normal file
49
meal-plan-frontend-mobile/src/components/SimpleBarChart.tsx
Normal 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' },
|
||||
});
|
||||
61
meal-plan-frontend-mobile/src/context/AppearanceContext.tsx
Normal file
61
meal-plan-frontend-mobile/src/context/AppearanceContext.tsx
Normal 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;
|
||||
}
|
||||
101
meal-plan-frontend-mobile/src/context/AuthContext.tsx
Normal file
101
meal-plan-frontend-mobile/src/context/AuthContext.tsx
Normal 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 };
|
||||
@@ -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]);
|
||||
}
|
||||
26
meal-plan-frontend-mobile/src/i18n/index.ts
Normal file
26
meal-plan-frontend-mobile/src/i18n/index.ts
Normal 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;
|
||||
385
meal-plan-frontend-mobile/src/i18n/locales/en.json
Normal file
385
meal-plan-frontend-mobile/src/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
387
meal-plan-frontend-mobile/src/i18n/locales/pl.json
Normal file
387
meal-plan-frontend-mobile/src/i18n/locales/pl.json
Normal 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"
|
||||
}
|
||||
}
|
||||
76
meal-plan-frontend-mobile/src/theme/appearance.ts
Normal file
76
meal-plan-frontend-mobile/src/theme/appearance.ts
Normal 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];
|
||||
}
|
||||
23
meal-plan-frontend-mobile/src/types/auth.ts
Normal file
23
meal-plan-frontend-mobile/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
219
meal-plan-frontend-mobile/src/types/diet.ts
Normal file
219
meal-plan-frontend-mobile/src/types/diet.ts
Normal 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;
|
||||
}
|
||||
149
meal-plan-frontend-mobile/src/types/generator.ts
Normal file
149
meal-plan-frontend-mobile/src/types/generator.ts
Normal 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: '',
|
||||
};
|
||||
33
meal-plan-frontend-mobile/src/types/ingredient-catalog.ts
Normal file
33
meal-plan-frontend-mobile/src/types/ingredient-catalog.ts
Normal 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;
|
||||
}
|
||||
24
meal-plan-frontend-mobile/src/types/meal-plan.ts
Normal file
24
meal-plan-frontend-mobile/src/types/meal-plan.ts
Normal 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[];
|
||||
}
|
||||
6
meal-plan-frontend-mobile/src/types/recipe-management.ts
Normal file
6
meal-plan-frontend-mobile/src/types/recipe-management.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RecipeImportResult {
|
||||
createdCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
createdRecipeIds: number[];
|
||||
}
|
||||
66
meal-plan-frontend-mobile/src/types/recipe.ts
Normal file
66
meal-plan-frontend-mobile/src/types/recipe.ts
Normal 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 }>;
|
||||
}
|
||||
19
meal-plan-frontend-mobile/src/utils/apiError.ts
Normal file
19
meal-plan-frontend-mobile/src/utils/apiError.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
18
meal-plan-frontend-mobile/src/utils/config.ts
Normal file
18
meal-plan-frontend-mobile/src/utils/config.ts
Normal 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(/\/$/, '');
|
||||
}
|
||||
39
meal-plan-frontend-mobile/src/utils/date.ts
Normal file
39
meal-plan-frontend-mobile/src/utils/date.ts
Normal 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 };
|
||||
}
|
||||
82
meal-plan-frontend-mobile/src/utils/diet-normalize.ts
Normal file
82
meal-plan-frontend-mobile/src/utils/diet-normalize.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
55
meal-plan-frontend-mobile/src/utils/pdfExport.ts
Normal file
55
meal-plan-frontend-mobile/src/utils/pdfExport.ts
Normal 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>`;
|
||||
}
|
||||
39
meal-plan-frontend-mobile/src/utils/recipe.ts
Normal file
39
meal-plan-frontend-mobile/src/utils/recipe.ts
Normal 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;
|
||||
}
|
||||
53
meal-plan-frontend-mobile/src/utils/sort.ts
Normal file
53
meal-plan-frontend-mobile/src/utils/sort.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
17
meal-plan-frontend-mobile/tsconfig.json
Normal file
17
meal-plan-frontend-mobile/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user