Nutrition Feature Guide
Definitive guide to the Nutrition feature — meal planning, recipe management, consumption tracking, and nutritional profiles across all platforms.
Introduction
The Nutrition Feature is a comprehensive meal planning, tracking, and management system that enables users to:
- Create and manage personal recipe libraries
- Plan meals by assigning recipes to specific days of the week
- Track daily meal consumption
- Skip planned meals with alternative options
- Search, filter, and sort recipes by multiple criteria
- Monitor nutritional intake across macro and micronutrients
This guide establishes the standards, patterns, and business rules that govern all nutrition feature implementations across desktop (web/desktop apps) and mobile platforms.
Business Logic & Rules
2.1 Core Concepts
Recipe
A recipe is a meal definition containing:
- Basic Info: Name, optional image, optional notes
- Nutritional Data: Calories, protein, carbs, fat, fiber, sugar, sodium (all optional)
- Ingredients: List of ingredient items (name, quantity, unit)
- Instructions: Ordered steps for preparation
- Scheduling: Days of week assignment (0-7 for Sun-Sat or 1-7 for Mon-Sun)
- Categorization: Meal tag (BREAKFAST, LUNCH, DINNER, SNACK, DESSERT, etc.)
Recipe Track
A consumption record that tracks when a recipe was consumed or skipped:
- Consumed: User ate the planned recipe
- Skipped: User ate something else instead
- Alternative Data: If skipped, stores what was eaten instead (recipe ID or free-form meal name + nutrients)
Day Assignment System
Recipes can be assigned to specific days of the week:
- Uses integer representation: 1 = Monday, 2 = Tuesday, …, 7 = Sunday
- One recipe can be assigned to multiple days
- One day can have multiple recipes
- Recipes without day assignments exist in the general library
2.2 Business Rules
Recipe Management
R-001: Recipe Creation
- Name is required (non-empty string)
- All nutritional values are optional but must be non-negative if provided
- Ingredients list can be empty but should be encouraged
- Instructions can be empty but recommended for completeness
- User can only create recipes for themselves
- Each recipe is owned by a single user
R-002: Recipe Updates
- Users can only update their own recipes
- Updating ingredients/instructions replaces the entire list (not incremental)
- Changing day assignments creates new RecipeDay records (old ones soft-deleted)
- Update does not affect existing consumption tracks
R-003: Recipe Deletion
- Soft delete: Recipe marked as DELETED, not physically removed
- Related RecipeDay records also marked as DELETED
- Ingredients and Instructions can be cascade deleted (hard delete acceptable)
- Existing RecipeTrack records remain intact for historical tracking
R-004: Recipe Ownership
- Recipes are user-scoped; users cannot see other users’ recipes
- All queries must filter by user_id
- Recipe IDs are globally unique (UUID) but always queried with user context
Day-Based Meal Planning
P-001: Day Assignment
- Recipes can be assigned to 0-7 days (0 = not assigned to any day, 1-7 = Mon-Sun)
- Assigning to multiple days creates multiple RecipeDay records
- Removing a day assignment soft-deletes the RecipeDay record
- Users can view “recipes for today” based on current day of week
- New recipes land in the library unscheduled (
days = []). The AddEditRecipeDialog no longer defaults to “every day” — users pick days via the interactive DayBadges or the per-day picker on the Today/Week tabs.
P-001a: Toggling a single day
- Use
POST /nutrition/recipes/{id}/days/{day}andDELETE /nutrition/recipes/{id}/days/{day}to add/remove one day without resending the whole recipe. - Both are idempotent and safe to retry. The optimistic client updates
Recipe.daysin the local state before the server ack. - Preferred over the full PATCH when the UI only edits a single day (Library DayBadges tap, Week/Today picker, per-tile trash button).
P-002: Dashboard Display
- Dashboard shows recipes assigned to current day of week
- Total recipe count reflects user’s entire library (not just today)
- Today’s recipes show consumption status (consumed/unconsumed)
- Consumed status is determined by checking RecipeTrack for today’s date range in the caller’s timezone (see Timezone convention). The server derives
todayfromresolveUserTimeZone(userId), notTimeZone.currentSystemDefault()— the recipe-tracks window ingetRecipesByDayandgetRecipesByDayWithFiltersuses the user’s stored IANA zone so late-night consumption on a client in a UTC-offset zone doesn’t fall outside the server’s day window.
P-003: Weekly Planning
- Users can plan meals for entire week by assigning recipes to days
- No enforcement of “one meal per time slot” - users can assign multiple breakfast recipes to Monday
- Meal tags (BREAKFAST, LUNCH, DINNER) are organizational, not scheduling constraints
P-004: Meal Reminders
- Today’s planned recipes trigger a “meal starting soon” push when their meal-tag time (
defaultBreakfastTime/defaultLunchTime/defaultDinnerTime/defaultSnackTimeonUserSettings) is withindefaultMealReminderOffsetMinutes(default 15,0to disable) - Already-
consumedrecipes for today are skipped - See Default reminder offsets
Consumption Tracking
T-001: Consuming a Recipe
- User marks a recipe as consumed at specific date/time
- Creates RecipeTrack record with:
- recipeId: The planned recipe
- consumedDateTime: When it was consumed
- skipped: false
- userId: Track owner
- A recipe can only be consumed once per day (per business logic, not enforced by DB constraint)
- Consuming a recipe does not remove it from the meal plan (can consume same recipe tomorrow)
T-002: Skipping a Recipe
- User can skip a planned recipe and record what they ate instead
- Creates RecipeTrack record with:
- recipeId: The originally planned recipe (to track what was skipped)
- consumedDateTime: When the alternative was consumed
- skipped: true
- alternativeRecipeId: If ate another recipe from library
- alternativeMealName: Free-text name if ate something not in library
- alternativeNutrients: Nutritional data for the alternative
- If alternative is another recipe, fetch its nutrients
- If alternative is free-form, user manually enters nutrients
T-003: Undo Consumption
- Users can remove a consumption track (e.g., if logged by mistake)
- Soft delete: RecipeTrack marked as DELETED
- Recipe returns to “unconsumed” state in UI
- Does not delete the original recipe
T-004: Historical Tracking
- All consumption tracks are preserved (even if recipe later deleted)
- Users can view consumption history by date range
- History shows both consumed and skipped meals
- Skipped meals display alternative meal information
Filtering & Search
F-001: Text Search
- Search by recipe name (case-insensitive, partial match)
- Uses SQL LIKE with wildcards:
%search_term% - Search applies across all user’s recipes regardless of day assignment
F-002: Meal Type Filter
- Filter by one or more meal tags (BREAKFAST, LUNCH, DINNER, SNACK, DESSERT)
- If multiple tags selected, uses OR logic (show recipes matching any tag)
- If no tag filter, show all recipes
F-003: Day Filter
- Filter recipes assigned to specific days (e.g., show only Monday recipes)
- If multiple days selected, uses OR logic
- If day filter active, only shows recipes with RecipeDay assignments matching selected days
- Requires JOIN with RecipeDays table
F-004: Nutritional Range Filters
- Support min/max ranges for: calories, protein, carbs, fat, fiber, sugar, sodium
- All ranges are inclusive (>= min, <= max)
- If only min provided, no upper bound
- If only max provided, no lower bound
- Multiple nutritional filters use AND logic (must satisfy all)
F-005: Sorting
- Support sorting by: name, calories, protein, carbs, fat, fiber, sugar
- Support ascending/descending order
- Default sort: name ascending
- Only one sort field active at a time
F-006: Filter Combination
- All filters combine with AND logic:
- (name matches) AND (meal type in list) AND (day in list) AND (calories in range) AND (protein in range) …
- Filters are optional; if not provided, that filter is ignored
- Empty result set if no recipes match all filters
Pagination
PG-001: Pagination Parameters
limit: Number of results per page (default: 50, max: 200)offset: Page number (0-indexed)- Actual SQL offset =
offset * limit - Always return
totalCountso client can calculate total pages
PG-002: Pagination Behavior
- Sorting is applied before pagination
- Filters are applied before pagination
- If user requests offset beyond available pages, return empty array
- TotalCount reflects filtered result count, not all recipes
2.3 Validation Rules
V-001: Recipe Name
- Required, non-empty
- Max length: 255 characters
- No special validation (can contain any characters)
V-002: Nutritional Values
- All optional
- Must be non-negative (>= 0) if provided
- Stored as doubles
- UI may round for display but store full precision
V-003: Ingredients
- Name: required, non-empty, max 255 characters
- Quantity: must be positive (> 0)
- Unit: required, non-empty, max 50 characters
V-004: Instructions
- StepNumber: positive integer, should be sequential
- Description: required, non-empty, stored as text (unlimited length)
V-005: Meal Tags
- Must match enum: BREAKFAST, LUNCH, DINNER, SNACK, DESSERT (case-sensitive)
- If invalid value provided, fallback to null (don’t reject request)
- Optional field
V-006: Day Assignments
- Must be integers 1-7
- If invalid values provided (0, 8, -1, etc.), ignore those specific values
- Don’t reject entire request for partial invalid data
V-007: Consumption DateTime
- Must be valid ISO 8601 format:
YYYY-MM-DDTHH:mm:ss - Timezone handling: store as provided, convert to UTC server-side
- Historical tracking: accept past dates
- Future dates: accept (pre-logging allowed)
2.4 Error Handling
E-001: Recipe Not Found
- HTTP 404
- Message: “Recipe not found”
- Occurs when: user requests recipe that doesn’t exist or belongs to another user
E-002: Unauthorized Access
- HTTP 403
- Message: “Unauthorized access to recipe”
- Occurs when: user tries to modify/delete another user’s recipe
E-003: Validation Failure
- HTTP 400
- Message: Specific validation error (e.g., “Recipe name is required”)
- Return first validation error encountered
E-004: Server Error
- HTTP 500
- Message: “Internal server error”
- Log full stack trace server-side
- Don’t expose internal details to client
Desktop Design Standards
3.1 Platform Contexts
Desktop includes:
- Desktop Application (Kotlin Compose Multiplatform - Desktop target)
- Web Application (Kotlin Compose Multiplatform - JS/WASM target)
Desktop design prioritizes:
- Larger screen real estate
- Mouse/trackpad interaction
- Keyboard navigation
- Multi-column layouts
- Rich filtering UI
- Batch operations
3.2 Layout Standards
Recipe List View
Layout Type: Grid or Table
-
Grid Mode: Card-based layout with 2-4 columns depending on screen width
- Each card shows: image (if available), name, meal tag badge, key nutrients
- Hover state reveals action buttons (edit, delete, consume, skip)
- Click to open detail view
-
Table Mode: Traditional table with columns
- Columns: Image (thumbnail), Name, Meal Type, Calories, Protein, Carbs, Fat, Days, Actions
- Sortable columns (click header to toggle sort)
- Row hover highlights entire row
- Inline actions in final column
Recommendation: Default to Grid for visual appeal, provide toggle to Table for data-heavy users
Dashboard View
Layout Structure:
+--------------------------------------------------+
| Header: "Nutrition Dashboard" |
| Subtitle: "Today's Meal Plan - Monday" |
+--------------------------------------------------+
| Summary Cards Row |
| [Total Recipes] [Today's Meals] [Completed] |
+--------------------------------------------------+
| Today's Recipes Grid (2-3 columns) |
| [Recipe Card] [Recipe Card] [Recipe Card] |
| Each card shows consumption status |
+--------------------------------------------------+
| Quick Actions |
| [View All Recipes] [Plan This Week] |
+--------------------------------------------------+Key Elements:
- Summary metrics prominently displayed
- Clear indication of current day
- Visual distinction between consumed/unconsumed recipes
- Quick access to common actions
Recipe Detail View
Layout Options:
Option A: Modal/Dialog
- Overlays current view
- Dismissible with ESC or close button
- Suitable for quick view/edit
Option B: Side Panel
- Slides in from right
- Allows viewing list and detail simultaneously
- Better for comparison workflows
Option C: Full Page
- Dedicated route/screen
- Most space for content
- Best for complex editing
Recommendation: Side panel for view, full page for create/edit
Detail View Structure:
+--------------------------------------------------+
| [< Back] [Edit] [Delete] |
+--------------------------------------------------+
| Recipe Image (large, optional placeholder) |
+--------------------------------------------------+
| Recipe Name (H1) |
| Meal Tag Badge Days: Mon, Wed, Fri |
+--------------------------------------------------+
| Nutritional Information (Grid) |
| Calories: 450 Protein: 30g Carbs: 45g |
| Fat: 15g Fiber: 8g Sugar: 5g |
+--------------------------------------------------+
| Ingredients |
| - 2 cups chicken breast |
| - 1 tbsp olive oil |
+--------------------------------------------------+
| Instructions |
| 1. Heat oil in pan |
| 2. Cook chicken until golden |
+--------------------------------------------------+
| Actions |
| [Mark as Consumed] [Skip with Alternative] |
+--------------------------------------------------+Recipe Form (Create/Edit)
Form Structure:
-
Section 1: Basic Information
- Name (required, text input)
- Image URL (optional, text input with preview)
- Meal Tag (optional, dropdown)
- Notes (optional, multiline text)
-
Section 2: Nutritional Information
- Grid layout with labeled number inputs
- Optional fields clearly marked
- Unit labels (g, mg, kcal) embedded
-
Section 3: Scheduling
- Day selector (checkboxes or toggle buttons for Mon-Sun)
- Visual indication of selected days
-
Section 4: Ingredients
- Dynamic list with add/remove
- Each row: Name, Quantity, Unit
- Drag to reorder (optional enhancement)
-
Section 5: Instructions
- Dynamic list with add/remove
- Auto-numbered steps
- Multiline text area for each step
- Drag to reorder (recommended)
-
Form Actions
- Save (primary button)
- Cancel (secondary button)
- Delete (danger button, only on edit mode)
Validation Feedback:
- Inline validation on blur
- Error messages below fields
- Prevent submission until valid
- Highlight invalid fields
3.3 Interactive Components
Recipe Card (Grid Mode)
Visual Design:
+--------------------------------+
| [Recipe Image or Placeholder] |
| |
+--------------------------------+
| Recipe Name |
| [BREAKFAST badge] |
| 450 cal • 30g protein |
| Days: M W F |
| |
| [Actions: Edit Delete Track] |
+--------------------------------+States:
- Default: Subtle border, white background
- Hover: Elevated shadow, actions visible
- Consumed: Green checkmark overlay or green border
- Selected: Blue border (if multi-select enabled)
Actions on Hover/Focus:
- Edit icon button
- Delete icon button
- Consume icon button (checkmark)
- Skip icon button (x or swap icon)
Filter Panel
Layout: Sidebar or Collapsible Panel
Filter Groups:
-
Search
- Text input with search icon
- Clear button when text present
- Debounced search (300ms delay)
-
Meal Type
- Checkboxes for each type
- “Select All” / “Clear All” toggles
-
Days of Week
- Checkboxes or toggle buttons
- Visual day indicators (M T W T F S S)
-
Nutritional Ranges
- Dual range sliders or min/max number inputs
- Show current range values
- Reset button per nutrient
-
Sorting
- Dropdown for sort field
- Toggle for ascending/descending
Filter Actions:
- Apply Filters (if not auto-applying)
- Clear All Filters
- Save Filter Preset (optional enhancement)
Behavior:
- Filters update results in real-time (debounced) OR on “Apply” click
- Active filters visually indicated (badges with counts)
- Filter state persisted in URL query params for shareability
Consumption Tracking UI
Consume Recipe Flow:
- User clicks “Mark as Consumed” on recipe card or detail
- Option A: Instant action (optimistic UI update)
- Option B: Confirmation dialog with datetime picker
- Default: current date/time
- Allow editing if user wants to log past consumption
- Show success notification
- Update recipe card to show consumed state
Skip Recipe Flow:
- User clicks “Skip” on recipe
- Modal/dialog opens: “Skip [Recipe Name]”
- Options:
- Choose Alternative Recipe: Dropdown/search of user’s recipes
- Enter Custom Meal: Free-text name + optional nutrient inputs
- DateTime picker (default: now)
- Confirm to save
- Show success notification
- Update UI to show skipped state (different from consumed)
Undo Consumption Flow:
- Recipe shows as consumed with “Undo” button
- User clicks “Undo”
- Option A: Instant undo (optimistic)
- Option B: Confirmation: “Remove consumption record?”
- Update UI back to unconsumed state
3.4 View Modes
Desktop should support multiple view modes for recipes:
Mode 1: Plan View (Weekly Planning)
- Shows calendar week view
- Each day column shows assigned recipes
- Drag-and-drop to assign recipes to days (enhancement)
- Consumption status visible per recipe
- Filter recipes in sidebar, drag to days to assign
Mode 2: Database View (Library)
- Shows all user recipes regardless of day assignment
- Full filtering and sorting
- Grid or table layout
- Primary use: browsing, searching, managing recipe library
Mode 3: History View (Consumption Log)
- Date range selector
- Shows consumption tracks grouped by date
- Displays consumed and skipped meals
- For skipped meals, shows alternative meal data
- Useful for tracking what user actually ate vs. planned
View Mode Switcher:
- Tab bar or segmented control
- Persist selected mode in user preferences
- Each mode has appropriate default filters
3.5 Keyboard Navigation
Desktop must support efficient keyboard navigation:
Global Shortcuts:
Ctrl/Cmd + N: New RecipeCtrl/Cmd + F: Focus search inputCtrl/Cmd + K: Open command palette (if implemented)ESC: Close modals/dialogs
List Navigation:
Arrow Keys: Navigate between recipe cardsEnter: Open focused recipe detailSpace: Select/deselect recipe (if multi-select)Delete: Delete focused recipe (with confirmation)
Form Navigation:
Tab: Move to next fieldShift + Tab: Move to previous fieldEnter: Submit form (if in text input)ESC: Cancel/close form
3.6 Responsive Breakpoints (for Web Desktop)
- Large Desktop (>= 1440px): 4-column grid, full sidebar
- Desktop (1024px - 1439px): 3-column grid, full sidebar
- Tablet (768px - 1023px): 2-column grid, collapsible sidebar
- Mobile (<768px): 1-column grid, use mobile design standardsMobile Design Standards
4.1 Platform Context
Mobile includes:
- Android Application (Kotlin Android)
- iOS Application (Kotlin Multiplatform - iOS target, if implemented)
Mobile design prioritizes:
- Touch-first interaction
- Single-column layouts
- Gesture navigation
- Bottom sheets and modals
- Simplified filtering (don’t overwhelm with options)
- Quick actions
4.2 Layout Standards
Recipe List View (Mobile)
Layout: Vertical List with Cards
+----------------------------------+
| [Search bar] |
| [Filter button] [Sort dropdown] |
+----------------------------------+
| Recipe Card |
| +----------------------------+ |
| | [Image] Name | |
| | 450 cal • 30g p | |
| | [BREAKFAST] | |
| +----------------------------+ |
| |
| Recipe Card |
| +----------------------------+ |
| | [Image] Name | |
| | 350 cal • 20g p | |
| | [LUNCH] | |
| +----------------------------+ |
+----------------------------------+Card Design:
- Image on left (thumbnail), content on right
- Two-line title (ellipsis overflow)
- Single line for key nutrients
- Meal tag badge
- Swipe gestures for actions (optional):
- Swipe right: Mark as consumed
- Swipe left: Delete or Edit options
Dashboard View (Mobile)
Layout: Vertical Scroll
+----------------------------------+
| Dashboard |
| Today: Monday, Dec 29 |
+----------------------------------+
| Summary Cards |
| [Total: 24] [Today: 3] [Done:1] |
+----------------------------------+
| Today's Meals |
| +----------------------------+ |
| | Breakfast | |
| | Recipe Card | |
| | [✓ Consumed] | |
| +----------------------------+ |
| | Lunch | |
| | Recipe Card | |
| | [Mark as Consumed] | |
| +----------------------------+ |
| | Dinner | |
| | Recipe Card | |
| | [Mark as Consumed] | |
| +----------------------------+ |
+----------------------------------+Key Characteristics:
- Summary at top (quick metrics)
- Grouped by meal type for clarity
- Clear consumption status
- Tap card to view details
- Tap button to mark consumed
Recipe Detail View (Mobile)
Layout: Scrollable Full-Screen
+----------------------------------+
| [< Back] [Edit] [Delete] |
+----------------------------------+
| Recipe Image (full width) |
+----------------------------------+
| Recipe Name |
| [BREAKFAST] Days: M W F |
+----------------------------------+
| Nutritional Info (2-col grid) |
| Calories 450 Protein 30g |
| Carbs 45g Fat 15g |
| Fiber 8g Sugar 5g |
+----------------------------------+
| Ingredients |
| • 2 cups chicken breast |
| • 1 tbsp olive oil |
+----------------------------------+
| Instructions |
| 1. Heat oil in pan |
| 2. Cook chicken until golden |
+----------------------------------+
| [Mark as Consumed] |
| [Skip with Alternative] |
+----------------------------------+Interaction:
- Swipe down to close (if modal)
- Sticky header with actions
- Floating action button for primary action (consume)
Recipe Form (Mobile)
Layout: Scrollable Form with Sections
- Use native mobile input types (number, text, etc.)
- Collapsible sections to reduce overwhelm:
- Basic Info (expanded by default)
- Nutritional Info (collapsed)
- Scheduling (collapsed)
- Ingredients (expanded)
- Instructions (expanded)
- Bottom action bar with Save/Cancel
- Use bottom sheet for picking meal tag and days
4.3 Interactive Components (Mobile)
Filter Bottom Sheet
Trigger: Tap “Filter” button in top bar
Layout:
+----------------------------------+
| Filters [X] |
+----------------------------------+
| Search |
| [Text input] |
+----------------------------------+
| Meal Type |
| [Breakfast] [Lunch] [Dinner] |
| (Toggle buttons) |
+----------------------------------+
| Days |
| [M] [T] [W] [T] [F] [S] [S] |
| (Toggle buttons) |
+----------------------------------+
| Nutritional Ranges |
| Calories: [min] - [max] |
| Protein: [min] - [max] |
| (Expandable for more nutrients) |
+----------------------------------+
| [Clear All] [Apply Filters] |
+----------------------------------+Behavior:
- Modal bottom sheet (slides up from bottom)
- Filters not applied until “Apply Filters” tapped
- “Clear All” resets all filters
- Dismiss by swiping down or tapping X
Consumption Tracking (Mobile)
Consume Recipe:
- Tap “Mark as Consumed” button
- Show brief confirmation toast: “Recipe marked as consumed”
- Update UI instantly (optimistic update)
- If fails, revert and show error
Skip Recipe:
- Tap “Skip” button
- Bottom sheet appears: “Skip this recipe”
- Two tabs:
- Choose Recipe: List of user’s recipes (searchable)
- Custom Meal: Free-text input + optional nutrient inputs
- Tap option to select
- Tap “Confirm Skip”
- Update UI to show skipped
Undo Consumption:
- Show “Undo” button on success snackbar/toast
- Tap to reverse action
- Snackbar auto-dismisses after 5s
4.4 Gestures
Swipe Actions on Recipe Cards:
- Swipe Right (short): Quick consume
- Swipe Left (short): Show action menu (edit, delete)
- Long Press: Multi-select mode (for batch operations)
Pull to Refresh:
- Pull down on recipe list to refresh from server
Swipe to Dismiss:
- Bottom sheets and modals dismissible by swiping down
4.5 Mobile-Specific Patterns
Tab Navigation:
+----------------------------------+
| [Dashboard] [Recipes] [History] |
+----------------------------------+
| |
| (Content for selected tab) |
| |
+----------------------------------+Floating Action Button:
- Bottom-right corner
- Primary action: “Add New Recipe”
- Tap to open create recipe form
Contextual Actions:
- Three-dot menu for secondary actions
- Bottom sheets for action lists
Search:
- Collapsible search bar (icon in top bar)
- Tap to expand into full-width search input
- Dismiss to collapse
Architecture Patterns
5.1 Layer Architecture
All platforms follow Clean Architecture principles:
+--------------------------------------------------+
| PRESENTATION LAYER |
| - Screens/Views (Composables) |
| - ViewModels (State management) |
| - Intents (User actions) |
| - Effects (Side effects) |
| - State (UI state models) |
+--------------------------------------------------+
↕
+--------------------------------------------------+
| DOMAIN LAYER |
| - Use Cases (Business logic) |
| - Repository Interfaces |
| - Domain Models |
+--------------------------------------------------+
↕
+--------------------------------------------------+
| DATA LAYER |
| - Repository Implementations |
| - Data Sources (Remote/Local) |
| - API Clients |
| - DTOs (Data Transfer Objects) |
| - Mappers (DTO ↔ Domain) |
+--------------------------------------------------+5.2 State Management Pattern
Use MVI (Model-View-Intent) Pattern:
// State
data class RecipesState(
val recipes: List<Recipe> = emptyList(),
val totalRecipes: Int = 0,
val isLoading: Boolean = false,
val error: String? = null,
val filters: RecipeFilters = RecipeFilters(),
val viewMode: ViewMode = ViewMode.DATABASE
)
// Intent
sealed class RecipesIntent {
data class GetRecipes(val filters: RecipeFilters) : RecipesIntent()
data class SearchRecipes(val query: String) : RecipesIntent()
data class ConsumeRecipe(val recipeId: String, val dateTime: String) : RecipesIntent()
data class DeleteRecipe(val recipeId: String) : RecipesIntent()
// ... more intents
}
// Effect
sealed class RecipesEffect {
data class ShowSnackBar(val message: String, val type: SnackBarType) : RecipesEffect()
data class NavigateToDetail(val recipeId: String) : RecipesEffect()
// ... more effects
}
// ViewModel
class RecipesViewModel : BaseViewModel<RecipesState, RecipesIntent, RecipesEffect>() {
override fun handleIntent(intent: RecipesIntent) {
when (intent) {
is RecipesIntent.GetRecipes -> getRecipes(intent.filters)
is RecipesIntent.SearchRecipes -> searchRecipes(intent.query)
// ... handle other intents
}
}
private fun getRecipes(filters: RecipeFilters) {
emitState { copy(isLoading = true) }
viewModelScope.launch {
recipeUseCases.getAll(filters).onSuccess { result ->
emitState {
copy(
recipes = result.recipes,
totalRecipes = result.totalCount,
isLoading = false
)
}
}.onFailure { error ->
emitState { copy(isLoading = false, error = error.message) }
emitEffect(RecipesEffect.ShowSnackBar(
message = "Failed to load recipes",
type = SnackBarType.ERROR
))
}
}
}
}5.3 Data Flow Pattern
Read Flow (Get Recipes):
User Action (Search)
↓
Intent: SearchRecipes("chicken")
↓
ViewModel: handleIntent
↓
UseCase: searchRecipes.invoke("chicken")
↓
Repository: searchRecipes("chicken")
↓
Remote DataSource: api.searchRecipes("chicken")
↓
Server API: GET /nutrition/recipes?search=chicken
↓
Server: Query database, filter, map to DTOs
↓
Response: RecipesResponseDTO(recipes, totalCount)
↓
DataSource: receives response
↓
Repository: maps DTOs to domain models
↓
UseCase: returns Result<RecipesResult>
↓
ViewModel: updates state with recipes
↓
View: recomposes with new recipesWrite Flow (Create Recipe):
User Action (Save Recipe)
↓
Intent: SaveRecipe(recipe)
↓
ViewModel: handleIntent
↓
UseCase: addRecipe.invoke(recipe)
↓
Repository: addRecipe(recipe)
↓
Remote DataSource: api.addRecipe(recipe.toDTO())
↓
Server API: POST /nutrition/recipes
↓
Server: Validate, create records, return ID
↓
Response: UUID
↓
Repository: returns Result<UUID>
↓
ViewModel: emits success effect
↓
Effect: ShowSnackBar("Recipe created successfully")
↓
View: shows success message, navigates back5.4 Error Handling Pattern
Layer-by-Layer Error Handling:
Data Layer:
suspend fun getRecipes(): Result<RecipesResponse> {
return try {
val response = api.getRecipes()
Result.success(response)
} catch (e: Exception) {
Result.failure(NetworkException("Failed to fetch recipes", e))
}
}Domain Layer:
operator fun invoke(filters: RecipeFilters): Result<RecipesResult> {
return repository.getRecipes(filters)
.map { response ->
RecipesResult(
recipes = response.recipes.map { it.toDomain() },
totalCount = response.totalCount
)
}
}Presentation Layer:
private fun getRecipes() {
viewModelScope.launch {
emitState { copy(isLoading = true) }
recipeUseCases.getAll(currentFilters).fold(
onSuccess = { result ->
emitState {
copy(
recipes = result.recipes,
totalRecipes = result.totalCount,
isLoading = false,
error = null
)
}
},
onFailure = { error ->
emitState {
copy(
isLoading = false,
error = error.toUserMessage()
)
}
emitEffect(RecipesEffect.ShowError(error.toUserMessage()))
}
)
}
}5.5 Offline and data policy (Mobile)
Mobile nutrition uses the shared Room + BaseRepository.doRequest stack with user offline_mode, local_db_enabled, and DataPolicyMode from Firebase Remote Config (fallback: env / Gradle — see canonical doc). For the full client model (stale-while-revalidate for tasks/habits, policy modes, Wasm note), see Offline & sync.
Data Models & Schema
6.1 Domain Models (Shared - Kotlin Multiplatform)
File Location: /shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/
@Serializable
data class Recipe(
val id: String,
val name: String,
val protein: Double? = null,
val calories: Double? = null,
val carbs: Double? = null,
val fat: Double? = null,
val fiber: Double? = null,
val sugar: Double? = null,
val sodium: Double? = null,
val image: String? = null,
val note: String? = null,
val days: List<Int>? = emptyList(), // 1-7 for Mon-Sun
val mealTag: String? = null, // BREAKFAST, LUNCH, DINNER, SNACK, DESSERT
val consumed: Boolean = false,
val consumedDateTime: String? = null,
val consumedTrackId: String? = null,
val ingredients: List<Ingredient> = emptyList(),
val instructions: List<Instruction> = emptyList()
)
@Serializable
data class Ingredient(
val id: String? = null,
val name: String,
val quantity: Double,
val unit: String
)
@Serializable
data class Instruction(
val id: String? = null,
val stepNumber: Int,
val description: String
)
@Serializable
data class RecipeTrack(
val id: String,
val recipe: Recipe,
val skipped: Boolean,
val consumedDateTime: String?,
val skippedRecipe: Recipe? = null // Alternative recipe data if skipped
)
@Serializable
data class CreateRecipeTrack(
val recipeId: String,
val consumedDateTime: String,
val skipped: Boolean = false,
val alternativeRecipeId: String? = null,
val alternativeMealName: String? = null,
val alternativeNutrients: AlternativeNutrients? = null
)
@Serializable
data class AlternativeNutrients(
val protein: Double? = null,
val calories: Double? = null,
val carbs: Double? = null,
val fat: Double? = null,
val fiber: Double? = null,
val sugar: Double? = null,
val sodium: Double? = null
)
@Serializable
data class RecipeFilters(
val searchPattern: String? = null,
val mealTypes: List<String>? = null,
val days: List<String>? = null,
val minCalories: Double? = null,
val maxCalories: Double? = null,
val minProtein: Double? = null,
val maxProtein: Double? = null,
val minCarbs: Double? = null,
val maxCarbs: Double? = null,
val minFat: Double? = null,
val maxFat: Double? = null,
val minFiber: Double? = null,
val maxFiber: Double? = null,
val minSugar: Double? = null,
val maxSugar: Double? = null,
val minSodium: Double? = null,
val maxSodium: Double? = null,
val sortField: RecipeSortField = RecipeSortField.NAME,
val sortOrder: RecipeSortOrder = RecipeSortOrder.ASCENDING
)
enum class RecipeSortField {
NAME, CALORIES, PROTEIN, CARBS, FAT, FIBER, SUGAR, SODIUM
}
enum class RecipeSortOrder {
ASCENDING, DESCENDING, NONE
}
@Serializable
data class RecipesResponse(
val recipes: List<Recipe>,
val totalCount: Long
)
@Serializable
data class NutritionDashboard(
val totalRecipes: Int,
val recipesForToday: List<Recipe>
)6.2 Database Schema (Server - PostgreSQL)
Recipes Table:
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
protein DOUBLE PRECISION,
calories DOUBLE PRECISION,
carbs DOUBLE PRECISION,
fat DOUBLE PRECISION,
fiber DOUBLE PRECISION,
sugar DOUBLE PRECISION,
sodium DOUBLE PRECISION,
image VARCHAR(255),
note TEXT,
meal_tag VARCHAR(20), -- BREAKFAST, LUNCH, DINNER, SNACK, DESSERT
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, DELETED
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_recipes_user ON recipes(user_id);
CREATE INDEX idx_recipes_status ON recipes(status);
CREATE INDEX idx_recipes_meal_tag ON recipes(meal_tag);Ingredients Table:
CREATE TABLE ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
quantity DOUBLE PRECISION NOT NULL,
unit VARCHAR(50) NOT NULL,
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id);
CREATE INDEX idx_ingredients_product_id ON ingredients(product_id);Optional productId links an ingredient to a product (products table, type INGREDIENT) — the same catalog populated by receipt scans, manual entry, and web imports. The server resolves name from products.canonical_name on save. Clients load pickers via GET /products/catalog?productType=INGREDIENT. See Products & price tracking for the full schema and ingestion paths.
Instructions Table:
CREATE TABLE instructions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
step_number INTEGER NOT NULL,
description TEXT NOT NULL,
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_instructions_recipe ON instructions(recipe_id);Recipe Days Table:
CREATE TABLE recipe_days (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
day INTEGER NOT NULL, -- 1=Mon, 2=Tue, ..., 7=Sun
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_recipe_days_recipe ON recipe_days(recipe_id);
CREATE INDEX idx_recipe_days_day ON recipe_days(day);
CREATE INDEX idx_recipe_days_user ON recipe_days(user_id);
CREATE INDEX idx_recipe_days_status ON recipe_days(status);Recipe Tracks Table:
CREATE TABLE recipe_tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
consumed_date_time TIMESTAMP NOT NULL,
skipped BOOLEAN NOT NULL DEFAULT false,
alternative_recipe_id UUID REFERENCES recipes(id),
alternative_meal_name VARCHAR(255),
protein DOUBLE PRECISION,
calories DOUBLE PRECISION,
carbs DOUBLE PRECISION,
fat DOUBLE PRECISION,
fiber DOUBLE PRECISION,
sugar DOUBLE PRECISION,
sodium DOUBLE PRECISION,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_recipe_tracks_recipe ON recipe_tracks(recipe_id);
CREATE INDEX idx_recipe_tracks_user ON recipe_tracks(user_id);
CREATE INDEX idx_recipe_tracks_date ON recipe_tracks(consumed_date_time);
CREATE INDEX idx_recipe_tracks_status ON recipe_tracks(status);6.3 Data Relationships
User (1) ←→ (N) Recipes
↓
Recipe (1) ←→ (N) Ingredients
Recipe (1) ←→ (N) Instructions
Recipe (1) ←→ (N) RecipeDays
Recipe (1) ←→ (N) RecipeTracks
RecipeTrack (N) → (1) Recipe (original planned recipe)
RecipeTrack (N) → (0..1) Recipe (alternative recipe, if chosen from library)API Specifications
7.1 Base URL
Production: https://api.oterapp.com/nutrition
Development: http://localhost:8080/nutrition7.2 Authentication
All endpoints require JWT bearer token:
Authorization: Bearer <token>User ID extracted from token, not passed in request.
7.3 Endpoints
GET /nutrition/dashboard
Description: Get dashboard summary and today’s recipes
Query Parameters:
day(optional, integer 1-7): Day of week (default: current day)
Response:
{
"totalRecipes": 24,
"recipesForToday": [
{
"id": "uuid",
"name": "Grilled Chicken",
"calories": 450.0,
"protein": 30.0,
"carbs": 45.0,
"fat": 15.0,
"fiber": 8.0,
"sugar": 5.0,
"sodium": 600.0,
"image": "https://...",
"note": "Delicious and healthy",
"days": [1, 3, 5],
"mealTag": "LUNCH",
"consumed": true,
"consumedDateTime": "2025-12-29T12:30:00",
"consumedTrackId": "uuid",
"ingredients": [...],
"instructions": [...]
}
]
}GET /nutrition/recipes
Description: Get paginated, filtered recipes
Query Parameters:
limit(integer, default: 50): Results per pageoffset(integer, default: 0): Page numbersearch(string): Search by namemealType(string, repeatable): Filter by meal typeday(integer, repeatable): Filter by day (1-7)minCalories,maxCalories(double): Calorie rangeminProtein,maxProtein(double): Protein rangeminCarbs,maxCarbs(double): Carbs rangeminFat,maxFat(double): Fat rangeminFiber,maxFiber(double): Fiber rangeminSugar,maxSugar(double): Sugar rangeminSodium,maxSodium(double): Sodium rangesortField(string): NAME, CALORIES, PROTEIN, CARBS, FAT, FIBER, SUGAR, SODIUMsortOrder(string): ASCENDING, DESCENDING
Response:
{
"recipes": [...],
"totalCount": 42
}GET /nutrition/recipes/all
Description: Get all recipes (no day filter, with other filters)
Query Parameters: Same as /recipes except no day
Response: Same as /recipes
GET /nutrition/recipes/byDay/{day}
Description: Get recipes assigned to specific day
Path Parameters:
day(integer 1-7): Day of week
Query Parameters: Same as /recipes
Response: Same as /recipes
GET /nutrition/recipes/{id}
Description: Get single recipe by ID
Path Parameters:
id(UUID): Recipe ID
Response:
{
"id": "uuid",
"name": "Grilled Chicken",
"calories": 450.0,
...
"ingredients": [...],
"instructions": [...]
}Error: 404 if not found or unauthorized
POST /nutrition/recipes
Description: Create new recipe
Request Body:
{
"name": "Grilled Chicken",
"protein": 30.0,
"calories": 450.0,
"carbs": 45.0,
"fat": 15.0,
"fiber": 8.0,
"sugar": 5.0,
"sodium": 600.0,
"image": "https://...",
"note": "Delicious",
"days": [1, 3, 5],
"mealTag": "LUNCH",
"ingredients": [
{
"name": "Chicken breast",
"quantity": 2.0,
"unit": "cups"
}
],
"instructions": [
{
"stepNumber": 1,
"description": "Heat oil in pan"
}
]
}Response:
- 201 Created
- Location header: /nutrition/recipes/{id}
- Body: Created recipe
Errors:
- 400 Bad Request: Validation errors
PATCH /nutrition/recipes/{id}
Description: Update existing recipe
Path Parameters:
id(UUID): Recipe ID
Request Body: Same as POST
Response:
- 200 OK: Updated recipe
- 404 Not Found
- 403 Forbidden: Not owner
DELETE /nutrition/recipes/{id}
Description: Soft delete recipe
Path Parameters:
id(UUID): Recipe ID
Response:
- 204 No Content
- 404 Not Found
- 403 Forbidden: Not owner
POST /nutrition/recipes/{id}/days/{day}
Description: Assign the recipe to an ISO weekday. Idempotent — if the recipe is already scheduled for that day, the call is a no-op and still returns 200. Complements PATCH /recipes/{id}, which does a full replacement of the day set.
Path Parameters:
id(UUID): Recipe IDday(integer 1-7): ISO weekday (1 = Monday, 7 = Sunday)
Response:
- 200 OK
- 400 Bad Request:
dayout of range - 404 Not Found: recipe missing or not owned by caller
DELETE /nutrition/recipes/{id}/days/{day}
Description: Unassign the recipe from an ISO weekday. Soft-deletes any ACTIVE recipe_days rows for that (recipe, day) pair.
Path Parameters:
id(UUID): Recipe IDday(integer 1-7): ISO weekday
Response:
- 200 OK
- 400 Bad Request:
dayout of range - 404 Not Found
POST /nutrition/tracking/consume
Description: Track recipe consumption or skip
Request Body:
{
"recipeId": "uuid",
"consumedDateTime": "2025-12-29T12:30:00",
"skipped": false,
"alternativeRecipeId": null,
"alternativeMealName": null,
"alternativeNutrients": null
}For Skip:
{
"recipeId": "uuid",
"consumedDateTime": "2025-12-29T12:30:00",
"skipped": true,
"alternativeRecipeId": "uuid", // OR null if custom meal
"alternativeMealName": "Pasta Carbonara", // If custom meal
"alternativeNutrients": {
"calories": 450.0,
"protein": 15.0,
"carbs": 60.0,
"fat": 18.0
}
}Response:
- 201 Created
- Body: Track ID
DELETE /nutrition/tracking/track/{id}
Description: Undo consumption (soft delete track)
Path Parameters:
id(UUID): Track ID
Response:
- 204 No Content
- 404 Not Found
GET /nutrition/tracking/range
Description: Get consumption history by date range
Query Parameters:
startDate(ISO date): Start dateendDate(ISO date): End date
Response:
[
{
"id": "uuid",
"recipe": {...},
"skipped": false,
"consumedDateTime": "2025-12-29T12:30:00",
"skippedRecipe": null
}
]GET /nutrition/tracking/recipe/{id}
Description: Get all tracks for specific recipe
Path Parameters:
id(UUID): Recipe ID
Response: Array of tracks
UI/UX Guidelines
8.1 Visual Design Principles
Color Coding
- Consumed State: Green accents (checkmark, border, badge)
- Skipped State: Orange/amber accents
- Unconsumed State: Neutral/gray
- Error State: Red accents
- Loading State: Shimmer effect or spinner
Meal Type Color Badges
- BREAKFAST: Yellow/gold
- LUNCH: Blue
- DINNER: Purple
- SNACK: Green
- DESSERT: Pink
Typography
- Heading 1 (Screen title): Bold, 24-28px
- Heading 2 (Section title): Semi-bold, 20-22px
- Heading 3 (Card title): Semi-bold, 16-18px
- Body: Regular, 14-16px
- Caption (metadata): Regular, 12-14px
8.2 Iconography
Recommended Icons:
- Add Recipe: Plus icon
- Edit: Pencil icon
- Delete: Trash icon
- Consume: Checkmark or check-circle icon
- Skip: X or swap icon
- Search: Magnifying glass icon
- Filter: Funnel icon
- Sort: Arrows (up/down) icon
- Calendar/Day: Calendar icon
- Ingredients: List icon
- Instructions: Numbered list icon
8.3 Loading States
Skeleton Screens:
- Show placeholder cards with shimmer effect
- Match layout of actual content
- Display immediately while data loads
Progress Indicators:
- Use indeterminate spinner for actions without known duration
- Use progress bar for operations with progress (e.g., batch operations)
8.4 Empty States
No Recipes:
+----------------------------------+
| [Illustration: Empty plate] |
| |
| No recipes yet |
| Create your first recipe to |
| start planning your meals |
| |
| [Create Recipe button] |
+----------------------------------+No Results from Filter:
+----------------------------------+
| [Illustration: Magnifying glass]|
| |
| No recipes found |
| Try adjusting your filters |
| |
| [Clear Filters button] |
+----------------------------------+No Meals for Today:
+----------------------------------+
| [Illustration: Calendar] |
| |
| No meals planned for today |
| Assign recipes to this day |
| |
| [View All Recipes button] |
+----------------------------------+8.5 Feedback & Notifications
Success Messages:
- “Recipe created successfully”
- “Recipe updated successfully”
- “Recipe deleted”
- “Marked as consumed”
- “Consumption removed”
Error Messages:
- “Failed to load recipes. Please try again.”
- “Failed to save recipe. Check your connection.”
- “Recipe not found”
- “You don’t have permission to edit this recipe”
Confirmation Dialogs:
- Delete Recipe: “Are you sure you want to delete [Recipe Name]? This action cannot be undone.”
- Discard Changes: “You have unsaved changes. Discard them?“
8.6 Accessibility
Contrast:
- Ensure WCAG AA compliance (4.5:1 for normal text, 3:1 for large text)
- Use color AND text/icons to convey state (not color alone)
Screen Reader Support:
- Label all interactive elements
- Announce state changes
- Provide context for dynamic content
Keyboard Navigation:
- All actions accessible via keyboard
- Visible focus indicators
- Logical tab order
Touch Targets:
- Minimum 44x44 points (iOS) or 48x48 dp (Android)
- Adequate spacing between interactive elements
Implementation Checklist
9.1 Backend Implementation Checklist
-
Database Schema
- Create Recipes table
- Create Ingredients table
- Create Instructions table
- Create RecipeDays table
- Create RecipeTracks table
- Set up foreign keys and cascades
- Create indexes for performance
-
Server Models
- Define RecipeDTO
- Define CreateRecipeDTO
- Define UpdateRecipeDTO
- Define RecipeTrackDTO
- Define CreateRecipeTrackDTO
- Define RecipesResponseDTO
- Define NutritionDashboardDTO
- Define RecipeFilters model
-
Repository Layer
- Implement NutritionRepository
- Implement CRUD methods
- Implement filtering logic
- Implement sorting logic
- Implement pagination
-
Service Layer
- Implement NutritionService
- Implement getDashboard
- Implement getRecipes with filters
- Implement createRecipe
- Implement updateRecipe
- Implement deleteRecipe (soft delete)
- Implement trackRecipeConsumption
- Implement getRecipeTracksByDateRange
- Implement deleteRecipeTrack
-
Routing
- Implement GET /nutrition/dashboard
- Implement GET /nutrition/recipes
- Implement GET /nutrition/recipes/all
- Implement GET /nutrition/recipes/byDay/{day}
- Implement GET /nutrition/recipes/{id}
- Implement POST /nutrition/recipes
- Implement PATCH /nutrition/recipes/{id}
- Implement DELETE /nutrition/recipes/{id}
- Implement POST /nutrition/tracking/consume
- Implement DELETE /nutrition/tracking/track/{id}
- Implement GET /nutrition/tracking/range
- Add authentication to all endpoints
- Add request validation
-
Converters
- Implement Recipe entity to DTO converter
- Implement RecipeTrack entity to DTO converter
- Implement DTO to entity converters
-
Testing
- Unit tests for service layer
- Integration tests for API endpoints
- Test filter combinations
- Test authentication and authorization
- Test soft delete behavior
9.2 Desktop Implementation Checklist
-
Shared Models
- Define Recipe model
- Define Ingredient model
- Define Instruction model
- Define RecipeTrack model
- Define CreateRecipeTrack model
- Define RecipeFilters model
- Define RecipesResponse model
- Define NutritionDashboard model
-
Data Layer
- Create NutritionApi interface
- Implement API client
- Create RecipesDataSource
- Create RecipesRepository implementation
- Implement DTO to domain model mappers
- Add error handling
-
Domain Layer
- Define RecipesRepository interface
- Create GetDashboard use case
- Create GetRecipes use case
- Create GetAllRecipes use case
- Create GetRecipesByDay use case
- Create SearchRecipes use case
- Create AddRecipe use case
- Create UpdateRecipe use case
- Create DeleteRecipe use case
- Create ConsumeRecipe use case
- Create SkipRecipe use case
- Create UndoConsumedRecipe use case
-
Presentation Layer
- Define RecipesState
- Define RecipesIntent
- Define RecipesEffect
- Create RecipesViewModel
- Define NutritionState
- Define NutritionIntent
- Define NutritionEffect
- Create NutritionViewModel
- Define NewEditRecipeState
- Define NewEditRecipeIntent
- Create NewEditRecipeViewModel
-
UI Components
- Create RecipeCard composable
- Create RecipeList composable
- Create RecipeGrid composable
- Create FilterPanel composable
- Create RecipeDetail composable
- Create RecipeForm composable
- Create IngredientInput composable
- Create InstructionInput composable
- Create ConsumeDialog composable
- Create SkipDialog composable
-
Screens
- Create NutritionScreen (Dashboard)
- Create RecipesScreen (List/Grid)
- Create RecipeDetailScreen
- Create NewEditRecipeScreen
- Implement navigation between screens
-
Features
- Implement search functionality
- Implement filtering (all filter types)
- Implement sorting
- Implement pagination
- Implement view mode switching (Plan/Database/History)
- Implement consumption tracking
- Implement skip with alternative
- Implement undo consumption
- Implement recipe CRUD operations
-
Testing
- Unit tests for ViewModels
- Unit tests for use cases
- UI tests for critical flows
9.3 Mobile Implementation Checklist
(Similar to desktop, but with mobile-specific considerations)
-
Mobile-Specific UI
- Implement mobile RecipeList (vertical)
- Implement mobile FilterBottomSheet
- Implement mobile ConsumeBottomSheet
- Implement mobile SkipBottomSheet
- Implement swipe gestures
- Implement pull-to-refresh
- Implement floating action button
-
Offline Support (if applicable)
- Create local database (Room/SQLDelight)
- Create LocalDataSource
- Implement caching logic
- Implement sync queue for offline operations
- Add offline mode toggle in settings
Recent changes (2026-06)
Kotlin Compose shared UI + server refactor. Landed together in the same session; documented here so future work sees the current state before the guide’s older layout descriptions.
Client — shared/NutritionScreen.kt
- Recipe create/edit is now a full screen, not a dialog. New
RecipeEditScreencomposable +RecipeEditRoute(recipeId: String? = null)typed route inAppNavHost.kt. Add/edit are opened vianavController.navigate(RecipeEditRoute(...)). The screen has section headers (Basics, Weekly schedule, Nutrition per serving, Ingredients, Instructions, Notes), a sticky bottom action bar (Save / Cancel / Delete-in-edit-mode), and a top clipboard row (Copy JSON / Paste JSON viaLocalClipboardManager). - Full-field editor. All Recipe fields are editable: name, meal-tag chips, scheduled time, image URL, weekly-schedule day badges (tap to toggle), the full nutrition grid (calories/protein/carbs/fat/fiber/sugar/sodium), a dynamic ingredients editor (add/remove rows, name + qty + unit), an instructions editor (numbered rows, move-up/down, delete), and a multiline notes field. Pasted-in extras (image, ingredients, instructions, days) round-trip through
buildRecipeFromForm. Recipe.sodium(client model) — added toshared/…/models/Recipe.ktto close a round-trip gap with the server DTO. Defaults to0.0; safe to send back on PATCH.- Skip-meal dialog rebuild. Removed the arbitrary
.take(20)cap, switched to a boundedLazyColumn(heightIn(max = 240.dp)), added a search field over the alternatives list, and a loader when the library is still fetching. The manual-entry section is hidden while the library list is showing to keep the layout focused. - Per-day add / remove UI in Today + Week + Library:
- Today: prominent “Add meal to today” CTA, plus a trash icon on each
MealRowthat unassigns today’s ISO day from the recipe. - Week:
+button in every day-card header opensAddMealPickerDialog(targetDay); eachPlanTilegets a trailing trash icon for one-tap remove. - Library:
DayBadgesare interactive — tapping a badge toggles the corresponding ISO day for the recipe. Tap target raised from 18dp → 22dp. - The picker filters out recipes already scheduled for the target day so the action is always additive; empty library surfaces a “New recipe” shortcut into the edit screen.
- Today: prominent “Add meal to today” CTA, plus a trash icon on each
- Consumed rows read as “already done”.
MealRowin Today now dims the meal icon (0.5 alpha), appliesTextDecoration.LineThroughto the name, adds a subtlesuccess.copy(alpha = 0.06f)row background, and wrapsMacroLinein aModifier.alpha(0.5f)box. Undo affordance is unchanged. - In-flight refresh indicator. Shared
TabRefreshBarcomposable renders a 2dpLinearProgressIndicatorat the top of Today/Library/Week/History whenever the correspondingstate.*Loadingis true. Existing content stays visible so scroll position isn’t lost. - Every tab reloads on selection.
LaunchedEffect(selectedTab)in the publicNutritionScreen(viewModel, ...)composable now callsrefresh()/loadLibrary(force = true)/loadWeek(force = true)/loadHistory(force = true)on each of the four tabs. The initial launch pays one duplicate Today fetch — cheap and worth the consistency. - Barcode → edit staging. Barcode “Found” no longer opens the old dialog. It calls
viewModel.stagePendingRecipeDraft(prefill)and navigates toRecipeEditRoute(); the edit screen seeds fromstate.pendingRecipeDraftand clears it viaconsumePendingRecipeDraft(). Routes can’t carry a fullRecipeobject, so this pattern replaces the previous transient client state. Recipe.daysno longer defaults to(1..7).toList(). New recipes (both the empty template and barcode-scanned drafts) land withdays = emptyList(). Previously a new recipe was accidentally scheduled for every weekday.
Client — shared/services/meals/NutritionViewModel.kt
- New VM methods:
assignRecipeToDay(recipeId, day),unassignRecipeFromDay(recipeId, day)— both do an optimistic update acrosslibraryRecipes,weekByDay, and today’srecipesbefore the server call, and reload the affected tabs on success (or on failure, after surfacingerrorMessage). fetchRecipe(recipeId)— thin wrapper overservice.getRecipeByIdused byRecipeEditScreenwhen the recipe isn’t in any cached state slice.stagePendingRecipeDraft/consumePendingRecipeDraft+NutritionUiState.pendingRecipeDraft— barcode staging.
Client — shared/services/meals/RecipesService.kt
assignRecipeToDay(recipeId, day)—POST /nutrition/recipes/{id}/days/{day}.unassignRecipeFromDay(recipeId, day)—DELETE /nutrition/recipes/{id}/days/{day}.
Server
- New routes in
NutritionRouting.kt:POST /nutrition/recipes/{id}/days/{day}→nutritionRepository.assignRecipeToDay(userId, id, day)DELETE /nutrition/recipes/{id}/days/{day}→nutritionRepository.unassignRecipeFromDay(userId, id, day)- Both validate
day ∈ 1..7at the route level and return 400 otherwise.
- New service methods in
NutritionService.kt:assignRecipeToDay(userId, id, day)— validates ownership, no-ops if an ACTIVERecipeDaysrow already exists for the pair, otherwise inserts a new row + history track.unassignRecipeFromDay(userId, id, day)— validates ownership, soft-deletes any ACTIVERecipeDaysrow for the pair.
- Timezone correctness fix in
NutritionService.getRecipesByDayandgetRecipesByDayWithFilters: the “consumed today” tracking window now usesresolveUserTimeZone(userId)instead ofTimeZone.currentSystemDefault(). Same class of bug documented in the Timezone convention — server-UTC “today” was drifting from the client’s local wall clock and marking freshly-consumed meals as unconsumed.
Follow-ups still pending
- Broader server tz audit —
StudyService,TimeTrackingService,WorkService,WorkStoreServicestill contain user-facing sites that should be migrated toresolveUserTimeZone. See the Timezone convention note for the audit status. - No persistent snackbar for
state.errorMessageyet — failed assign/unassign calls currently swallow silently after the optimistic revert.
End of Nutrition Feature Expert Guide
This document serves as the single source of truth for all nutrition feature implementations. Developers should refer to this guide when building or modifying nutrition-related functionality across any platform.
For questions or clarifications, contact the architecture team or submit a documentation update request.