A Swift iOS recipe browser built with SwiftUI on top of TheMealDB public API. Browse recipes by category and cuisine, search by name, read full recipes with ingredients and instructions, and save favorites locally for offline cooking.
The app follows a layered, testable design. Views own no business logic; everything flows through @Observable view models that depend only on protocols, so each layer is replaceable and unit-testable in isolation. SwiftData and URLSession are implementation details hidden behind service protocols; the view models never import them.
App/ Composition root: AppDependencies (DI graph), RecipesApp (@main)
Models/ MealSummary, MealDetail (+Ingredient, flattening decoder),
MealCategory, Area, MealImageSize, MealDBError (typed)
Services/ RecipeProviding → LiveMealDBClient (URLSession, async/await, off-main)
MealDBEndpoint (free V1 URL builder, test key "1")
FavoritesStoring → SwiftDataFavoritesStore (persistence)
ViewModels/ Browse / MealList / Search / Favorites / RecipeDetail (@Observable, @MainActor)
Views/ RootTabView shell + Browse / Search / Favorites / Detail + reusable Components
Persistence/ FavoriteRecipe (@Model)
Key decisions
| Decision | Why |
|---|---|
@Observable view models, no Combine |
Modern Swift state with async/await; one view model per tab, each tested through mocks. |
Protocol seams (RecipeProviding, FavoritesStoring) |
View models depend on abstractions, so tests inject mocks; no network or SwiftData required. |
Plain AppDependencies struct for DI |
Init-injection with a live default is the idiomatic "container"; no third-party DI framework. |
Typed MealDBError: LocalizedError |
Replaces untyped errors; call sites and tests switch on exact cases (.noResults, .badStatus, …). |
nonisolated, Sendable network client |
Requests and JSON decoding run off the main actor so the UI stays responsive. |
SwiftData behind FavoritesStoring |
SwiftData is an implementation detail, not the architecture, so the view models never import it. |
Custom Decodable for the 20 flat ingredient fields |
TheMealDB encodes ingredients as strIngredient1…20/strMeasure1…20; a dynamic-key decoder flattens them in one place. |
iOS 27 AsyncImage(request:) + asyncImageURLSession |
A shared URLCache keeps thumbnails from re-downloading on scroll-back. |
The app talks only to TheMealDB's free V1 endpoints using the public developer test key 1:
| Feature | Endpoint |
|---|---|
| Browse categories | categories.php |
| Browse cuisines | list.php?a=list |
| Meals by category / area | filter.php?c=… / filter.php?a=… |
| Search by name | search.php?s=… |
| Full recipe lookup | lookup.php?i=… |
No premium V2 features (multi-random, latest, multi-ingredient filters) are used. filter.php returns only id/name/thumbnail, so opening a meal performs a lookup.php for the full recipe.
This project uses TheMealDB's free test key 1 strictly for development and as a portfolio demonstration. Per TheMealDB's terms, shipping publicly on the App Store requires becoming a supporter for a production key, so this app is not intended for App Store release as configured. Recipe data and images are © their respective owners via TheMealDB.
- Browse: categories in an adaptive grid and cuisines (areas) in a list, each drilling into a filtered meal list.
- Search: debounced (300 ms), cancellable name search with a minimum query length and empty/no-result states.
- Recipe detail: hero image, category/area chips, ingredient list with measures, full instructions, and YouTube / source links.
- Favorites: one-tap heart toggle persisted with SwiftData; favorites render fully offline and support swipe-to-delete.
- Resilient networking: typed errors, off-main decoding, structured-concurrency cancellation, and retryable error states.
- Cached images:
AsyncImage(request:)over a sharedURLCache, with resized TheMealDB thumbnail variants for lists. - Modern SwiftUI:
TabView+ per-tabNavigationStack, value-based navigation, adaptive dark/light previews, and accessibility identifiers throughout. - Accessibility: VoiceOver labels and traits throughout, grouped rows and cards, hidden decorative glyphs, section headers, and a VoiceOver delete action for favorites.
The app is built to work with VoiceOver:
- Every interactive control has a spoken label. The favorite button announces "Add to favorites" or "Remove from favorites" based on its state, and the external links read as "Watch on YouTube" and "View Original Recipe".
- Meal rows, ingredient rows, and category cards are each grouped into a single element, so VoiceOver reads "name, subtitle" in one swipe instead of fragmenting across the thumbnail and labels.
- The recipe detail hero image carries a "Photo of " label, and the section headers expose the Header trait so the headings rotor can jump between Ingredients and Instructions.
- Purely decorative glyphs (ingredient bullets, the globe and chevron on cuisine rows, empty-state symbols) are hidden from VoiceOver to cut announcement noise.
- Swipe-to-delete on Favorites is also exposed as a VoiceOver custom action ("Delete"), since swipe gestures are not otherwise reachable by assistive technologies.
- Stable accessibility identifiers back the UI test suite, so a renamed identifier breaks the tests at compile time rather than silently.
Captured on iPhone 17 (iOS 27).
| Screen | Light | Dark |
|---|---|---|
| Browse (Categories) | ![]() |
![]() |
| Browse (Cuisines) | ![]() |
![]() |
| Meal list | ![]() |
![]() |
| Recipe detail | ![]() |
![]() |
| Search | ![]() |
![]() |
| Favorites | ![]() |
![]() |
- Swift 6 (strict concurrency, default
@MainActorisolation) - SwiftUI + Observation (
@Observable) - SwiftData (favorites persistence)
- iOS 27 / Xcode 27
URLSessionasync/await networking- Swift Testing (logic) + XCTest / XCUIAutomation (view models & UI)
- SwiftLint
- GitHub Actions (CI)
The suite is split by what it verifies; the protocol seams make business logic testable without the network or disk.
| Suite | Layer | Coverage |
|---|---|---|
MealDecodingTests |
Models | Flattening of the 20 ingredient/measure fields, tag splitting, URL parsing, meals: null handling, summary/category/area decode. |
LiveMealDBClientTests |
Networking | URL building per endpoint, success decode, non-2xx → .badStatus, meals: null → [] / .noResults, malformed JSON → .decoding, via a StubURLProtocol. |
FavoriteRecipeMappingTests |
Persistence | MealDetail ⇄ FavoriteRecipe round-trip, including ingredient flattening and empty-measure handling. |
BrowseViewModelTests |
View model | Category/area loading and typed error surfacing. |
SearchViewModelTests |
View model | Min-length guard, query trimming, results mapping, error clears results. |
FavoritesViewModelTests |
View model | Load and remove against a mock store. |
RecipeDetailViewModelTests |
View model | Summary→lookup vs. detail-in-hand paths, favorite toggle. |
RecipesUITests |
UI | Tab navigation, search → favorite → appears in Favorites, browse categories; hermetic via the -uitest-stub launch argument. |
Pure-logic suites use Swift Testing (@Suite/@Test/#expect). The @MainActor view-model and persistence suites use XCTest, and UI tests use XCUIAutomation. This split is deliberate: on the current iOS 27 beta toolchain, app-hosted Swift Testing suites isolated to a global actor crash the runner, and a SwiftData fetch against a @testable-imported @Model traps inside the test bundle. The store's CRUD is verified in the running app; its mapping and the favorites flow are unit-tested above.
Released under the MIT License. © 2026 SarahUniverse











