* Extended functionalities
* Added different UIs
This commit is contained in:
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user