├── CuisineLaunchLogo.png
├── Cuisine System Design.png
├── Cuisine
├── Assets.xcassets
│ ├── Contents.json
│ ├── Meal Categories
│ │ ├── Contents.json
│ │ ├── beef.imageset
│ │ │ ├── beef.png
│ │ │ └── Contents.json
│ │ ├── goat.imageset
│ │ │ ├── goat.png
│ │ │ └── Contents.json
│ │ ├── lamb.imageset
│ │ │ ├── lamb.png
│ │ │ └── Contents.json
│ │ ├── pork.imageset
│ │ │ ├── pork.png
│ │ │ └── Contents.json
│ │ ├── side.imageset
│ │ │ ├── side.png
│ │ │ └── Contents.json
│ │ ├── pasta.imageset
│ │ │ ├── pasta.png
│ │ │ └── Contents.json
│ │ ├── vegan.imageset
│ │ │ ├── vegan.png
│ │ │ └── Contents.json
│ │ ├── breakfast.imageset
│ │ │ ├── Eggs.png
│ │ │ └── Contents.json
│ │ ├── chicken.imageset
│ │ │ ├── chicken.png
│ │ │ └── Contents.json
│ │ ├── dessert.imageset
│ │ │ ├── dessert.png
│ │ │ └── Contents.json
│ │ ├── seafood.imageset
│ │ │ ├── seafood.png
│ │ │ └── Contents.json
│ │ ├── starter.imageset
│ │ │ ├── starter.png
│ │ │ └── Contents.json
│ │ ├── vegetarian.imageset
│ │ │ ├── vegetarian.png
│ │ │ └── Contents.json
│ │ └── miscellaneous.imageset
│ │ │ ├── miscellaneous.png
│ │ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── CuisineLogo.png
│ │ └── Contents.json
│ └── AccentColor.colorset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Models
│ ├── LoadingState.swift
│ ├── MealFilter.swift
│ ├── MealsResponse.swift
│ ├── MealDetailsResponse.swift
│ ├── MealDetailAbout.swift
│ ├── MealCategory.swift
│ ├── Meal.swift
│ └── MealDetail.swift
├── CuisineApp.swift
├── Extensions
│ ├── EdgeInsets+.swift
│ ├── View+.swift
│ ├── String+.swift
│ ├── URLRequest+.swift
│ └── URLSession+.swift
├── View Modifiers
│ ├── SectionTitle.swift
│ └── RoundedRectBackground.swift
├── Main
│ ├── Views
│ │ ├── Recipe Details
│ │ │ ├── RecipeDetailsInstructionsView.swift
│ │ │ ├── RecipeDetailsTagsView.swift
│ │ │ ├── RecipeDetailsHeaderView.swift
│ │ │ ├── Empty View
│ │ │ │ └── EmptyDetailsView.swift
│ │ │ ├── RecipeDetailsIngredientsView.swift
│ │ │ ├── View Model
│ │ │ │ └── RecipeDetailsViewModel.swift
│ │ │ ├── RecipeDetailsAboutView.swift
│ │ │ ├── Skeleton View
│ │ │ │ └── SkeletonDetailsView.swift
│ │ │ └── RecipeDetailsView.swift
│ │ ├── Empty View
│ │ │ └── EmptyMealsView.swift
│ │ ├── RecipeView.swift
│ │ ├── Toolbar
│ │ │ └── MealFilterButton.swift
│ │ ├── MealCategoryView.swift
│ │ ├── Skeleton View
│ │ │ └── SkeletonMealsView.swift
│ │ └── MainView.swift
│ └── View Model
│ │ └── MainViewModel.swift
├── Error Handling
│ └── MealError.swift
├── Cache
│ ├── RemoteImage.swift
│ ├── ImageCache.swift
│ └── ImageLoader.swift
├── Services
│ ├── Endpoints
│ │ └── FetchAPI.swift
│ ├── Network
│ │ └── Network.swift
│ └── MealService.swift
└── Localization
│ └── Localizable.xcstrings
├── Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.17.50.png
├── Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.18.15.png
├── Cuisine.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── xcshareddata
│ └── xcschemes
│ │ └── Cuisine.xcscheme
└── project.pbxproj
├── CuisineUITests
├── CuisineUITestsLaunchTests.swift
└── CuisineUITests.swift
├── LICENSE
├── CuisineTests
├── Network Tests
│ ├── MockURLProtocol.swift
│ ├── MockDataJSON.swift
│ └── CuisineNetworkTests.swift
├── Main Tests
│ └── CuisineTests.swift
└── Localization Tests
│ └── CuisineLocalizationTests.swift
└── README.md
/CuisineLaunchLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/CuisineLaunchLogo.png
--------------------------------------------------------------------------------
/Cuisine System Design.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine System Design.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Cuisine/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/AppIcon.appiconset/CuisineLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/AppIcon.appiconset/CuisineLogo.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/beef.imageset/beef.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/beef.imageset/beef.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/goat.imageset/goat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/goat.imageset/goat.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/lamb.imageset/lamb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/lamb.imageset/lamb.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/pork.imageset/pork.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/pork.imageset/pork.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/side.imageset/side.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/side.imageset/side.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/pasta.imageset/pasta.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/pasta.imageset/pasta.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/vegan.imageset/vegan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/vegan.imageset/vegan.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/breakfast.imageset/Eggs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/breakfast.imageset/Eggs.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/chicken.imageset/chicken.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/chicken.imageset/chicken.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/dessert.imageset/dessert.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/dessert.imageset/dessert.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/seafood.imageset/seafood.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/seafood.imageset/seafood.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/starter.imageset/starter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/starter.imageset/starter.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/vegetarian.imageset/vegetarian.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/vegetarian.imageset/vegetarian.png
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/miscellaneous.imageset/miscellaneous.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Cuisine/Assets.xcassets/Meal Categories/miscellaneous.imageset/miscellaneous.png
--------------------------------------------------------------------------------
/Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.17.50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.17.50.png
--------------------------------------------------------------------------------
/Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.18.15.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NolanOfficial/Cuisine/HEAD/Simulator Screenshot - Clone 1 of iPhone 15 Pro Max - 2024-06-08 at 14.18.15.png
--------------------------------------------------------------------------------
/Cuisine.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Cuisine/Models/LoadingState.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoadingState.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/12/24.
6 | //
7 |
8 | /// The loading state of a view
9 | enum LoadingState {
10 | case loading
11 | case result
12 | case error
13 | case empty
14 | }
15 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealFilter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealFilter.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // Different filtering methods for meals
11 | enum MealFilter: String, CaseIterable {
12 | case alphabetical
13 | case id
14 | }
15 |
--------------------------------------------------------------------------------
/Cuisine/CuisineApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineApp.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/4/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct CuisineApp: App {
12 |
13 | var body: some Scene {
14 | WindowGroup {
15 | MainView()
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Cuisine/Extensions/EdgeInsets+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EdgeInsets+.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension EdgeInsets {
11 | static var zero: EdgeInsets {
12 | return .init(top: 0, leading: 0, bottom: 0, trailing: 0)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "CuisineLogo.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Cuisine.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "platform" : "ios",
6 | "reference" : "labelColor"
7 | },
8 | "idiom" : "universal"
9 | }
10 | ],
11 | "info" : {
12 | "author" : "xcode",
13 | "version" : 1
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealsResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealsResponse.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // This would usually be accompanied with a status message and optional status codes
11 | struct MealsResponse: Decodable {
12 | let meals: [Meal]
13 | }
14 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealDetailsResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealDetailsResponse.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // This would usually be accompanied with a status message and optional status codes
11 | struct MealDetailsResponse: Decodable {
12 | let meals: [MealDetail]
13 | }
14 |
--------------------------------------------------------------------------------
/Cuisine/Extensions/View+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension View {
11 | func roundedRectBackground() -> some View {
12 | modifier(RoundedRectBackground())
13 | }
14 |
15 | func sectionTitle() -> some View {
16 | modifier(SectionTitle())
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Cuisine/Extensions/String+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension String {
11 | /// Localizes a given string
12 | ///
13 | /// - Important: variable strings do not get localized with `Text` views.
14 | /// It's important to add this method to ensure localization.
15 | var localized: LocalizedStringKey {
16 | return LocalizedStringKey(self)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/goat.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "goat.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/lamb.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "lamb.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/pasta.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "pasta.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/pork.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "pork.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/side.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "side.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/vegan.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "vegan.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/View Modifiers/SectionTitle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SectionTitle.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SectionTitle: ViewModifier {
11 | func body(content: Content) -> some View {
12 | content
13 | .listRowInsets(.zero)
14 | .listRowSeparator(.hidden)
15 | .font(.headline)
16 | .foregroundStyle(.primary)
17 | .padding(.horizontal)
18 | .padding(.vertical, 10)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/breakfast.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Eggs.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/chicken.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "chicken.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/dessert.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "dessert.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/seafood.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "seafood.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/starter.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "starter.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/vegetarian.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "vegetarian.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/miscellaneous.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "miscellaneous.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "template-rendering-intent" : "template"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Cuisine/Assets.xcassets/Meal Categories/beef.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "beef.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | },
21 | "properties" : {
22 | "localizable" : true,
23 | "template-rendering-intent" : "template"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Cuisine/Extensions/URLRequest+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLRequest+.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/9/24.
6 | //
7 |
8 | import Foundation
9 |
10 | extension URLRequest {
11 |
12 | /// Custom URL Request that enables and returns cached urls
13 | ///
14 | /// - Parameter url: The url in which to make the request for
15 | static func cuisineRequest(_ url: URL) -> URLRequest {
16 | var request = URLRequest(url: url)
17 | request.cachePolicy = .returnCacheDataElseLoad
18 | return request
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Cuisine/View Modifiers/RoundedRectBackground.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RoundedRectBackground.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RoundedRectBackground: ViewModifier {
11 | func body(content: Content) -> some View {
12 | content
13 | .frame(minWidth: 12)
14 | .font(.footnote.weight(.semibold))
15 | .padding(6)
16 | .foregroundStyle(.orange)
17 | .background(.regularMaterial)
18 | .clipShape(RoundedRectangle(cornerRadius: 8))
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsInstructionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsInstructionsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsInstructionsView: View {
11 |
12 | let instructions: String
13 |
14 | var body: some View {
15 | Text(instructions)
16 | .font(.subheadline)
17 | .padding(.horizontal)
18 | }
19 | }
20 |
21 | #Preview {
22 | RecipeDetailsInstructionsView(instructions: MealDetail.MOCK_MEAL_DETAIL.instructions ?? "No Mock Data")
23 | }
24 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsTagsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsTagsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsTagsView: View {
11 |
12 | let tags: String
13 |
14 | var body: some View {
15 | HStack {
16 | ForEach(tags.split(separator: ","), id: \.self) { tag in
17 | Text(tag)
18 | .roundedRectBackground()
19 | }
20 | Spacer()
21 | }
22 | .padding(.horizontal)
23 | }
24 | }
25 |
26 | #Preview {
27 | RecipeDetailsTagsView(tags: MealDetail.MOCK_MEAL_DETAIL.tags ?? "No Mock Data")
28 | }
29 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsHeaderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsHeaderView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsHeaderView: View {
11 |
12 | let meal: Meal
13 |
14 | var body: some View {
15 | Text(meal.name)
16 | .font(.system(size: 28, weight: .medium))
17 | HStack {
18 | Spacer()
19 | RemoteImage(url: meal.thumbnailUrl)
20 | .scaledToFit()
21 | .frame(height: 240)
22 | .clipShape(RoundedRectangle(cornerRadius: 16))
23 | Spacer()
24 | }
25 | }
26 | }
27 |
28 | #Preview {
29 | RecipeDetailsHeaderView(meal: .MOCK_MEAL)
30 | }
31 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealDetailAbout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealDetailAbout.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/8/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // Different information types about a recipe (meal)
11 | enum MealDetailAbout: String {
12 |
13 | case id
14 | case category
15 | case origin
16 | case youtube
17 | case website
18 |
19 | var imageName: String {
20 | switch self {
21 | case .id:
22 | return "number.circle.fill"
23 | case .category:
24 | return "list.bullet.rectangle"
25 | case .origin:
26 | return "map"
27 | case .youtube:
28 | return "video"
29 | case .website:
30 | return "safari"
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Cuisine/Error Handling/MealError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealError.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Custom errors for the Fetch API
11 | enum MealError: Error {
12 |
13 | case invalidUrl
14 | case unknown
15 |
16 | var title: String {
17 | switch self {
18 | case .invalidUrl:
19 | return "Invalid URL"
20 | case .unknown:
21 | return "Unknown Error"
22 | }
23 | }
24 |
25 | var message: String {
26 | switch self {
27 | case .invalidUrl:
28 | return "The url was invalid. Please try again later."
29 | case .unknown:
30 | return "Something went wrong. Please try again later."
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealCategory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealCategory.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // These could be retrieved through an endpoint but:
11 | // 1. The images weren't going to be used so an extra request was unnecessary
12 | // 2. Performace would've been impacted for something minimal
13 |
14 | // Real world, an endpoint would be used, but the data would also contain correct and more relevant information
15 | enum MealCategory: String, CaseIterable {
16 |
17 | case beef
18 | case breakfast
19 | case chicken
20 | case dessert
21 | case goat
22 | case lamb
23 | case miscellaneous
24 | case pasta
25 | case pork
26 | case seafood
27 | case side
28 | case starter
29 | case vegan
30 | case vegetarian
31 | }
32 |
--------------------------------------------------------------------------------
/Cuisine/Cache/RemoteImage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoteImage.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// View that automatically initializes the image cache
11 | struct RemoteImage: View {
12 |
13 | /// An observed image loader
14 | @ObservedObject var imageLoader: ImageLoader
15 |
16 | /// Initialized the image loader with the given url
17 | ///
18 | /// - Parameter url: The url in which to initialize the image loader
19 | init(url: URL?) {
20 | imageLoader = ImageLoader(url: url)
21 | }
22 |
23 | var body: some View {
24 | if let image = imageLoader.image {
25 | Image(uiImage: image)
26 | .resizable()
27 | } else {
28 | RoundedRectangle(cornerRadius: 10)
29 | .fill(.regularMaterial)
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/CuisineUITests/CuisineUITestsLaunchTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineUITestsLaunchTests.swift
3 | // CuisineUITests
4 | //
5 | // Created by Nolan Fuchs on 6/4/24.
6 | //
7 |
8 | import XCTest
9 |
10 | final class CuisineUITestsLaunchTests: XCTestCase {
11 |
12 | override class var runsForEachTargetApplicationUIConfiguration: Bool {
13 | true
14 | }
15 |
16 | override func setUpWithError() throws {
17 | continueAfterFailure = false
18 | }
19 |
20 | func testLaunch() throws {
21 | let app = XCUIApplication()
22 | app.launch()
23 |
24 | // Insert steps here to perform after app launch but before taking a screenshot,
25 | // such as logging into a test account or navigating somewhere in the app
26 |
27 | let attachment = XCTAttachment(screenshot: app.screenshot())
28 | attachment.name = "Launch Screen"
29 | attachment.lifetime = .keepAlways
30 | add(attachment)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Empty View/EmptyMealsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmptyMealsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EmptyMealsView: View {
11 |
12 | var body: some View {
13 | HStack {
14 | Spacer()
15 |
16 | VStack(alignment:.center, spacing: 16) {
17 | Image(systemName: "fork.knife.circle.fill")
18 | .resizable()
19 | .scaledToFit()
20 | .frame(width: 50, height: 50)
21 |
22 | VStack(spacing: 6) {
23 | Text("No Recipes Found")
24 | .font(.headline)
25 |
26 | Text("No recipes were found. Try another category.")
27 | .font(.footnote)
28 | .foregroundStyle(.secondary)
29 | }
30 | }
31 | Spacer()
32 | }
33 | }
34 | }
35 |
36 | #Preview {
37 | EmptyMealsView()
38 | }
39 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/RecipeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// This view will load the image from cache
11 | /// If none is available, it will load from the given url
12 | struct RecipeView: View {
13 |
14 | let meal: Meal
15 |
16 | var body: some View {
17 | HStack(spacing: 10) {
18 | RemoteImage(url: meal.thumbnailUrl)
19 | .scaledToFill()
20 | .frame(width: 120, height: 75)
21 | .clipShape(RoundedRectangle(cornerRadius: 10))
22 | .environment(\.colorScheme, .dark)
23 |
24 | VStack(alignment: .leading) {
25 | Text(meal.name)
26 | .font(.subheadline.weight(.semibold))
27 | Text("Id: \(meal.id)")
28 | .font(.footnote)
29 | .foregroundStyle(.secondary)
30 | }
31 | }
32 | }
33 | }
34 |
35 | #Preview {
36 | RecipeView(meal: .MOCK_MEAL)
37 | }
38 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/Empty View/EmptyDetailsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EmptyDetailsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/8/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EmptyDetailsView: View {
11 | var body: some View {
12 | HStack {
13 | Spacer()
14 |
15 | VStack(alignment:.center, spacing: 16) {
16 |
17 | Image(systemName: "exclamationmark.triangle")
18 | .resizable()
19 | .scaledToFit()
20 | .frame(width: 50, height: 50)
21 |
22 | VStack(spacing: 6) {
23 |
24 | Text("Recipe Details Error")
25 | .font(.headline)
26 |
27 | Text("Unable to load recipe. Please try again.")
28 | .font(.footnote)
29 | .foregroundStyle(.secondary)
30 | }
31 | }
32 | Spacer()
33 | }
34 | }
35 | }
36 |
37 | #Preview {
38 | EmptyDetailsView()
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Nolan Fuchs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Cuisine/Cache/ImageCache.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageCache.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// A simple custom cache for images
11 | ///
12 | /// - Note: 3rd party caches like Nuke or KingFisher are also other options
13 | class ImageCache {
14 |
15 | /// A cache object that stores a path string for each image
16 | private let cache = NSCache()
17 |
18 | /// Sets a path with the given key for the given image
19 | ///
20 | /// - Parameter image: UImage to cache
21 | /// - Parameter forKey: String that sets the path
22 | ///
23 | /// - Important: Ensure the key is the url path for synchronization purposes.
24 | func set(_ image: UIImage, forKey key: String) {
25 | cache.setObject(image, forKey: key as NSString)
26 | }
27 |
28 | /// Sets a path with the given key for the given image
29 | ///
30 | /// - Parameter key: String path in which the cache
31 | ///
32 | /// - Important: Ensure the key is the url path for synchronization purposes.
33 | func get(forKey key: String) -> UIImage? {
34 | return cache.object(forKey: key as NSString)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsIngredientsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsIngredientsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsIngredientsView: View {
11 |
12 | let mealDetail: MealDetail
13 |
14 | var body: some View {
15 |
16 | /// Apple's Collections Package `OrderedDictionary` is much more preferable here
17 | ForEach(Array(mealDetail.ingredientsMap.keys), id: \.self) { ingredient in
18 | HStack {
19 | Image(systemName: "fork.knife.circle")
20 | .imageScale(.large)
21 |
22 | Text(ingredient)
23 | .font(.subheadline)
24 |
25 | Spacer()
26 |
27 | Text(mealDetail.ingredientsMap[ingredient] ?? "Unkown")
28 | .font(.subheadline)
29 | .foregroundStyle(.secondary)
30 | }
31 | .padding(.horizontal)
32 | .padding(6)
33 | }
34 | }
35 | }
36 |
37 | #Preview {
38 | RecipeDetailsIngredientsView(mealDetail: .MOCK_MEAL_DETAIL)
39 | }
40 |
--------------------------------------------------------------------------------
/Cuisine/Extensions/URLSession+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLSession+.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/9/24.
6 | //
7 |
8 | import Foundation
9 |
10 | extension URLSession {
11 |
12 | /// Custom URL Session that enables `waitForConnectivity` and `urlCache`
13 | ///
14 | /// - Note: This could also be created within a custom network layer
15 | /// (on top of the service layer)
16 | static var cuisineConfiguration: URLSession {
17 |
18 | let configuration = URLSessionConfiguration.default
19 |
20 | // TCP
21 | // (note: disabled since this needs to be enabled server side as well)
22 | // configuration.multipathServiceType = .handover
23 | configuration.waitsForConnectivity = true
24 | configuration.timeoutIntervalForRequest = 15
25 |
26 | // Default capcity values but can increase
27 | let memoryCapacity = 4 * 1024 * 1024 // 4 MB
28 | let diskCapacity = 100 * 1024 * 1024 // 100 MB
29 | let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity)
30 | configuration.urlCache = urlCache
31 |
32 | return URLSession(configuration: configuration)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/CuisineTests/Network Tests/MockURLProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockURLProtocol.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 | import Foundation
8 | import XCTest
9 |
10 | class MockURLProtocol: URLProtocol {
11 |
12 | static var requestHandler: ((URLRequest) throws -> (Data, HTTPURLResponse))?
13 |
14 | override class func canInit(with request: URLRequest) -> Bool {
15 | return true
16 | }
17 |
18 | override class func canonicalRequest(for request: URLRequest) -> URLRequest {
19 | return request
20 | }
21 |
22 | override func startLoading() {
23 | guard let handler = MockURLProtocol.requestHandler else {
24 | XCTFail("No handler set")
25 | return
26 | }
27 |
28 | do {
29 | let (data, response) = try handler(request)
30 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
31 | client?.urlProtocol(self, didLoad: data)
32 | client?.urlProtocolDidFinishLoading(self)
33 | } catch {
34 | client?.urlProtocol(self, didFailWithError: error)
35 | }
36 | }
37 |
38 | override func stopLoading() {}
39 | }
40 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Toolbar/MealFilterButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealFilterButton.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MealFilterButton: View {
11 |
12 | @Binding var mealFilter: MealFilter
13 |
14 | var body: some View {
15 | Menu {
16 | ForEach(MealFilter.allCases, id: \.self) { filter in
17 | Button {
18 | mealFilter = filter
19 | } label: {
20 | HStack {
21 | Text(LocalizedStringKey(filter.rawValue.capitalized))
22 | Spacer()
23 | if mealFilter == filter {
24 | Image(systemName: "arrow.down")
25 | }
26 | }
27 | }
28 | .accessibilityLabel("\(filter.rawValue.capitalized) Recipe Filter")
29 | }
30 | } label: {
31 | Image(systemName: "slider.horizontal.3")
32 | }
33 | .foregroundStyle(mealFilter == .alphabetical ? .white : .orange)
34 | .accessibilityLabel("Recipe Filter Menu")
35 | }
36 | }
37 |
38 | #Preview {
39 | @State var filter: MealFilter = .alphabetical
40 | MealFilterButton(mealFilter: $filter)
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/View Model/RecipeDetailsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsViewModel.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/12/24.
6 | //
7 |
8 | import Foundation
9 |
10 | class RecipeDetailsViewModel: ObservableObject {
11 |
12 | // MARK: Services
13 |
14 | private let mealService = MealService()
15 |
16 | // MARK: Published values
17 |
18 | @Published var state: LoadingState = .loading
19 |
20 | @Published var mealDetail: MealDetail?
21 |
22 | @Published var mealError: MealError?
23 |
24 | @Published var showMealError = false
25 |
26 | // MARK: Functions
27 |
28 | /// Asynchronously downloads meal details from the meal service API
29 | ///
30 | /// - Parameter meal: The meal in which to get the deatils for
31 | ///
32 | /// - returns: The meal details downloaded
33 | @MainActor
34 | func getMealDetail(for meal: Meal) async {
35 | do {
36 | mealDetail = try await mealService.getMealDetails(for: meal)
37 | state = .result
38 | } catch let error as MealError {
39 | mealError = error
40 | showMealError = true
41 | state = .error
42 | } catch {
43 | mealError = MealError.unknown
44 | showMealError = true
45 | state = .error
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Cuisine/Services/Endpoints/FetchAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FetchAPI.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | /// This enum creates different endpoints for the Fetch API in which we can download from
11 | enum FetchAPI {
12 |
13 | /// Meal endpoint for a specific category
14 | case meal(category: MealCategory)
15 | /// Meal details endpoint for a specific meal
16 | case mealDetails(meal: Meal)
17 |
18 | /// The Fetch URL path subcomponent
19 | private var path: String {
20 | switch self {
21 | case .meal:
22 | return "/api/json/v1/1/filter.php"
23 | case .mealDetails:
24 | return "/api/json/v1/1/lookup.php"
25 | }
26 | }
27 |
28 | /// An array of query items for the Fetch URL in the order in which they appear in the original query string
29 | private var queryItems: [URLQueryItem] {
30 | switch self {
31 | case .meal(let category):
32 | return [
33 | URLQueryItem(name: "c", value: category.rawValue.capitalized)
34 | ]
35 | case .mealDetails(let meal):
36 | return [
37 | URLQueryItem(name: "i", value: meal.id)
38 | ]
39 | }
40 | }
41 |
42 | /// A Fetch URL created from the components.
43 | var url: URL? {
44 | var components = URLComponents()
45 | components.scheme = "https"
46 | components.host = "themealdb.com"
47 | components.path = path
48 | components.queryItems = queryItems
49 | return components.url
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/CuisineTests/Main Tests/CuisineTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineTests.swift
3 | // CuisineTests
4 | //
5 | // Created by Nolan Fuchs on 6/4/24.
6 | //
7 |
8 | import XCTest
9 | @testable import Cuisine
10 |
11 | final class CuisineTests: XCTestCase {
12 |
13 | @MainActor
14 | let viewModel = MainViewModel()
15 |
16 | /// Ensures view model is set with the correct starting properties
17 | /// Filter should be alphabetical
18 | /// Category should be dessert
19 | @MainActor
20 | func test_Dessert_Initialization() {
21 | XCTAssertEqual(viewModel.mealFilter, .alphabetical)
22 | XCTAssertEqual(viewModel.selectedCategory, .dessert)
23 | }
24 |
25 | /// Testing all filter options for meals
26 | @MainActor
27 | func test_Meal_Filter() async throws {
28 |
29 | viewModel.mealsSearchResults = Meal.MOCK_MEALS
30 |
31 | for filter in MealFilter.allCases {
32 | var sortedMeals = Meal.MOCK_MEALS
33 | viewModel.mealFilter = filter
34 |
35 | switch filter {
36 | case .alphabetical:
37 | sortedMeals = Meal.MOCK_MEALS.sorted(by: { $0.name < $1.name })
38 | case .id:
39 | sortedMeals = Meal.MOCK_MEALS.sorted(by: { $0.id < $1.id })
40 | }
41 |
42 | viewModel.filterMeals()
43 | XCTAssertEqual(sortedMeals, viewModel.mealsSearchResults)
44 | }
45 | }
46 |
47 | func test_Performance_Example() throws {
48 | // This is an example of a performance test case.
49 | self.measure {
50 | // Put the code you want to measure the time of here.
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsAboutView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsAboutView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsAboutView: View {
11 |
12 | let mealDetail: MealDetail
13 |
14 | var body: some View {
15 |
16 | ForEach(Array(mealDetail.aboutMap.keys).sorted(by: { $0 < $1 }), id: \.self) { about in
17 |
18 | if let aboutEnum = MealDetailAbout(rawValue: about), let detail = mealDetail.aboutMap[about] {
19 | HStack {
20 | Image(systemName: aboutEnum.imageName)
21 | .imageScale(.large)
22 |
23 | Text(about.capitalized.localized)
24 | .font(.subheadline)
25 |
26 | Spacer()
27 |
28 | if aboutEnum == .website || aboutEnum == .youtube {
29 | if let url = URL(string: detail) {
30 | Link(destination: url) {
31 | Text(aboutEnum == .website ? "Website" : "YouTube")
32 | .roundedRectBackground()
33 | }
34 | }
35 | } else {
36 | Text(detail.localized)
37 | .roundedRectBackground()
38 | }
39 | }
40 | .padding(.horizontal)
41 | .padding(6)
42 | }
43 | }
44 | }
45 | }
46 |
47 | #Preview {
48 | RecipeDetailsAboutView(mealDetail: .MOCK_MEAL_DETAIL)
49 | }
50 |
--------------------------------------------------------------------------------
/Cuisine/Cache/ImageLoader.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageLoader.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Class the handles all image loading and cache data
11 | @MainActor
12 | class ImageLoader: ObservableObject {
13 |
14 | private let imageCache = ImageCache()
15 |
16 | @Published var image: UIImage?
17 |
18 | @Published var imageError: Error?
19 |
20 | private var url: URL?
21 |
22 | /// Initializes the cache retrieval from the given url
23 | ///
24 | /// - Parameter url: The url path for a cache lookup
25 | init(url: URL?) {
26 | self.url = url
27 |
28 | Task {
29 | do {
30 | try await loadImage()
31 | } catch {
32 | // This is not an error we want to show the user directly.
33 | imageError = error
34 | }
35 | }
36 | }
37 |
38 | /// Loads image from cache, if available, or sets new image asynchronosuly into the cache
39 | private func loadImage() async throws {
40 |
41 | guard let url else { throw URLError(.badURL) }
42 |
43 | if let cachedImage = imageCache.get(forKey: url.absoluteString) {
44 | self.image = cachedImage
45 | return
46 | }
47 |
48 | // Custom URL session for downloading the data
49 | let (data, _) = try await URLSession.cuisineConfiguration.data(for: .cuisineRequest(url))
50 |
51 | // Setting the image and cache path, if available
52 | guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
53 | self.image = image
54 | imageCache.set(image, forKey: url.absoluteString)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Fetch's iOS Challenge
2 |
3 | # Welcome to *Cuisine*
4 | > Cuisine is an iOS app that displays food recipes from around the world
5 |
6 | ### What we support ✅
7 | - iOS 15.0+
8 | - Xcode 14.0+
9 |
10 | ### Built using 👷🏻
11 | - Pure SwiftUI
12 | - Xcode 15.4
13 | - Async/Await
14 |
15 | ### Features 😎
16 | - Light/Dark Mode
17 | - Select any food category
18 | - Get all available details for listed recipes
19 | - Search for a specific recipe
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ## For Dev Review
28 |
29 | ### Architecture
30 | > MVVM with an integrated service architecture
31 | > - seperates enpoints, services, and network requests from the view model
32 |
33 |
34 | ### View States
35 | - Loading
36 | - Result
37 | - Empty/Error
38 |
39 | ### Network
40 | - Custom url session configurations
41 | - Custom url request configurations
42 |
43 | ### Caching
44 | - All images are cached
45 | - All urls are cached
46 |
47 | ### Localization
48 | - English
49 | - Swedish
50 |
51 | ### Testing
52 | > Unit Tests
53 | - `CuisineLocalizationTests`
54 | - All locales exist
55 | - English localization
56 | - Swedish localization
57 |
58 | - `CuisineNetworkTests`
59 | - Success
60 | - Server fail
61 | - JSON fail
62 |
63 | - `CuisineMainTests`
64 | - Filter
65 | - Correct initialization
66 |
67 | > UI Tests
68 | - `CuisineUITests`
69 |
70 | - Dessert card exists
71 | - Dessert category exists
72 | - Filter alphabetical button exists
73 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/MealCategoryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealCategoryView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MealCategoryView: View {
11 |
12 | @Binding var category: MealCategory
13 |
14 | var body: some View {
15 | HStack {
16 | Text("Categories")
17 |
18 | Text(category.rawValue.capitalized.localized)
19 | .roundedRectBackground()
20 | }
21 | .sectionTitle()
22 |
23 | // ScrollView since we have a known small set number of items
24 | ScrollView(.horizontal, showsIndicators: false) {
25 |
26 | HStack(alignment: .bottom, spacing: 24) {
27 |
28 | ForEach(MealCategory.allCases, id: \.self) { cuisine in
29 |
30 | Button {
31 | category = cuisine
32 | } label: {
33 | VStack {
34 | Image(cuisine.rawValue)
35 | .resizable()
36 | .frame(width: 35, height: 35)
37 | .padding(6)
38 |
39 | Text(cuisine.rawValue.capitalized.localized)
40 | .font(.footnote)
41 | }
42 | .foregroundStyle(category == cuisine ? .orange : .primary)
43 | }
44 | .accessibilityIdentifier("\(cuisine.rawValue.capitalized) Meal Category")
45 | }
46 | }
47 | .padding(.horizontal)
48 | .padding(.bottom)
49 | .padding(.top, 8)
50 | }
51 | .listRowInsets(.zero)
52 | }
53 | }
54 |
55 | #Preview {
56 | MealCategoryView(category: .constant(.breakfast))
57 | }
58 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Skeleton View/SkeletonMealsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SkeletonMealsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SkeletonMealsView: View {
11 |
12 | @State private var isAnimating = false
13 |
14 | private let center = (UIScreen.main.bounds.width / 2) + 250
15 |
16 | private func skeletonView() -> some View {
17 | return VStack(alignment: .leading, spacing: 22) {
18 |
19 | Text("Recipes")
20 | .sectionTitle()
21 |
22 | ForEach(0..<5) { _ in
23 | HStack(spacing: 10) {
24 |
25 | RoundedRectangle(cornerRadius: 10)
26 | .fill(.quaternary)
27 | .frame(width: 120, height: 75)
28 |
29 | VStack(alignment: .leading) {
30 | Text("Lorem Ipsum")
31 | .font(.subheadline.weight(.semibold))
32 | Text("Lorem Ipsum")
33 | .font(.footnote)
34 | .foregroundStyle(.secondary)
35 | }
36 | }
37 | }
38 | }
39 | .redacted(reason: .placeholder)
40 | }
41 |
42 | var body: some View {
43 | skeletonView()
44 | .overlay {
45 | skeletonView()
46 | .mask {
47 | Rectangle()
48 | .fill(
49 | LinearGradient(gradient: .init(colors: [.clear, .white, .clear]), startPoint: .top, endPoint: .bottom)
50 | )
51 | .frame(width: UIScreen.main.bounds.height)
52 | .rotationEffect(.degrees(90))
53 | .offset(x: isAnimating ? center : -center)
54 | }
55 | }
56 | .onAppear {
57 | withAnimation(.easeInOut.speed(0.2).repeatForever(autoreverses: false)) {
58 | isAnimating.toggle()
59 | }
60 | }
61 | }
62 | }
63 |
64 | #Preview {
65 | SkeletonMealsView()
66 | }
67 |
--------------------------------------------------------------------------------
/Cuisine/Services/Network/Network.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Network.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol NetworkProtocol {
11 | func fetch(_ url: URL) async throws -> T
12 | }
13 |
14 | /// Handles all network requests
15 | actor Network: NetworkProtocol {
16 |
17 | /// Custom url session configuration
18 | private let urlSession: URLSession
19 |
20 | init(urlSession: URLSession = .cuisineConfiguration) {
21 | self.urlSession = urlSession
22 | }
23 |
24 | /// A reusable meal service JSON decoder
25 | ///
26 | /// - Important: .convertFromSnakeCase is disabled since it's not actually used.
27 | /// This is to indicate how it would work if the json matched the model.
28 | private let cuisineDecoder: JSONDecoder = {
29 | let decoder = JSONDecoder()
30 | // decoder.keyDecodingStrategy = .convertFromSnakeCase
31 | return decoder
32 | }()
33 |
34 | /// Retrieves and decodes the data from the given url
35 | ///
36 | /// - Parameter url: The url from where to retrieve the data
37 | ///
38 | /// - Returns: The decoded data
39 | func fetch(_ url: URL) async throws -> T {
40 |
41 | let (data, response) = try await urlSession.data(for: .cuisineRequest(url))
42 |
43 | guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
44 |
45 | // This would be seperated out with the corresponding server side errors
46 | // These codes are generic codes most commonly used
47 | guard httpResponse.statusCode == 200 else {
48 | switch httpResponse.statusCode {
49 | case 100..<200:
50 | throw URLError(.unknown)
51 | case 300..<400:
52 | throw URLError(.redirectToNonExistentLocation)
53 | case 400..<500:
54 | throw URLError(.fileDoesNotExist)
55 | case 500..<600:
56 | throw URLError(.badServerResponse)
57 | default:
58 | throw URLError(.unknown)
59 | }
60 | }
61 | return try cuisineDecoder.decode(T.self, from: data)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Cuisine/Models/Meal.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Meal.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // Important to note: Depending on the backend, these could also be optional
11 | struct Meal: Identifiable, Decodable, Hashable {
12 |
13 | let id: String
14 | let name: String
15 | let thumbnailUrl: URL?
16 |
17 | // Manual Coding Keys should be non-existent in newer project
18 | // synchronization is critical which reduces bugs and failure points
19 | // ---> using keyDecodingStrategies is preferred <----
20 | enum CodingKeys: String, CodingKey {
21 | case id = "idMeal"
22 | case name = "strMeal"
23 | case thumbnailUrl = "strMealThumb"
24 | }
25 | }
26 |
27 | extension Meal {
28 |
29 | static var MOCK_MEAL: Meal {
30 | .init(id: "53005", name: "Strawberry Rhubarb Pie", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/178z5o1585514569.jpg"))
31 | }
32 |
33 | static var MOCK_DESSERT_MEAL: Meal {
34 | .init(id: "53005", name: "Strawberry Rhubarb Pie", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/178z5o1585514569.jpg"))
35 | }
36 |
37 | static var MOCK_MEALS: [Meal] {
38 | [
39 | .init(id: "53005", name: "Strawberry Rhubarb Pie", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/178z5o1585514569.jpg")),
40 | .init(id: "52890", name: "Jam Roly-Poly", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/ysqupp1511640538.jpg")),
41 | .init(id: "52787", name: "Hot Chocolate Fudge", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/xrysxr1483568462.jpg")),
42 | .init(id: "52893", name: "Apple & Blackberry Crumble", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/xvsurr1511719182.jpg")),
43 | .init(id: "52988", name: "Classic Christmas pudding", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/1d85821576790598.jpg")),
44 | .init(id: "53022", name: "Polskie Naleśniki (Polish Pancakes)", thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/58bkyo1593350017.jpg"))
45 | ]
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/Skeleton View/SkeletonDetailsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SkeletonDetailsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/8/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SkeletonDetailsView: View {
11 |
12 | @State private var isAnimating = false
13 |
14 | private let center = (UIScreen.main.bounds.width / 2) + 250
15 |
16 | private func skeletonView() -> some View {
17 | return VStack(spacing: 22) {
18 |
19 | HStack {
20 | Text("Lorem Impsum")
21 | .sectionTitle()
22 | Spacer()
23 | }
24 |
25 | ForEach(0..<7) { _ in
26 | HStack(spacing: 10) {
27 | Rectangle()
28 | .fill(.quaternary)
29 | .frame(width: 15, height: 15)
30 |
31 | HStack {
32 | Text("Lorem ipsum")
33 | .font(.subheadline.weight(.semibold))
34 | Spacer()
35 | Text("Lorem ipsum dolor sit")
36 | .font(.subheadline.weight(.semibold))
37 | }
38 | }
39 | }
40 | }
41 | .redacted(reason: .placeholder)
42 | .padding()
43 | }
44 |
45 | var body: some View {
46 | skeletonView()
47 | .overlay {
48 | skeletonView()
49 | .mask {
50 | Rectangle()
51 | .fill(
52 | LinearGradient(gradient: .init(colors: [.clear, .white, .clear]), startPoint: .top, endPoint: .bottom)
53 | )
54 | .frame(width: UIScreen.main.bounds.height)
55 | .rotationEffect(.degrees(90))
56 | .offset(x: isAnimating ? center : -center)
57 | }
58 | }
59 | .onAppear {
60 | withAnimation(.easeInOut.speed(0.2).repeatForever(autoreverses: false)) {
61 | isAnimating.toggle()
62 | }
63 | }
64 | }
65 | }
66 |
67 | #Preview {
68 | SkeletonDetailsView()
69 | }
70 |
--------------------------------------------------------------------------------
/Cuisine/Services/MealService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealService.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Meal Service Protocol
11 | protocol MealServiceProtocol {
12 | func getMeals(for category: MealCategory) async throws -> [Meal]
13 | func getMealDetails(for meal: Meal) async throws -> MealDetail?
14 | }
15 |
16 | /// This class represents a service atchitecture within MVVM
17 | ///
18 | /// When managing a lot of different microservices, this is the best (and most common) approach
19 | /// as it seperates view models from having to manage service requests
20 | actor MealService: MealServiceProtocol {
21 |
22 | let network = Network()
23 |
24 | /// A reusable meal service JSON decoder
25 | ///
26 | /// A dynamic decoder could also be created if all services have matching decoding strategies
27 | ///
28 | /// - Important: .convertFromSnakeCase is disabled since it's not actually used.
29 | /// This is to indicate how it would work if the json matched the model.
30 | private let decoder: JSONDecoder = {
31 | let decoder = JSONDecoder()
32 | // decoder.keyDecodingStrategy = .convertFromSnakeCase
33 | return decoder
34 | }()
35 |
36 | /// Downloads all meals for the specified category
37 | ///
38 | /// - Parameter category: The meal category in which to get the meals from
39 | ///
40 | /// - Returns: an array of all retrieved meals
41 | func getMeals(for category: MealCategory) async throws -> [Meal] {
42 | // Ensuring the meal URL is valid
43 | guard let url = FetchAPI.meal(category: category).url else { throw MealError.invalidUrl }
44 | // Decoding the data
45 | let response: MealsResponse = try await network.fetch(url)
46 | // Return decoded meals
47 | return response.meals
48 | }
49 |
50 | /// Downloads all meal details for the specified meal
51 | ///
52 | /// - Parameter category: The meal in which to get the details from
53 | ///
54 | /// - Returns: An optional that contains the meal details
55 | func getMealDetails(for meal: Meal) async throws -> MealDetail? {
56 | // Ensuring the meal details URL is valid
57 | guard let url = FetchAPI.mealDetails(meal: meal).url else { throw MealError.invalidUrl }
58 | // Decoding the data
59 | let response: MealDetailsResponse = try await network.fetch(url)
60 | return response.meals.first
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Cuisine/Main/View Model/MainViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainViewModel.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// View Model that represents the Main View
11 | ///
12 | /// Contains all objects used for downloading and setting meals and meal details
13 | class MainViewModel: ObservableObject {
14 |
15 | // MARK: Services
16 |
17 | private let mealService = MealService()
18 |
19 | // MARK: Published values
20 |
21 | @Published var meals: [Meal] = []
22 |
23 | @Published var mealsSearchResults: [Meal] = []
24 |
25 | @Published var selectedCategory: MealCategory = .dessert
26 |
27 | @Published var mealFilter: MealFilter = .alphabetical
28 |
29 | @Published var state: LoadingState = .loading
30 |
31 | @Published var error: MealError? = nil
32 |
33 | @Published var showError = false
34 |
35 | // MARK: Functions
36 |
37 | /// Asynchronously downloads meals from the meal service API
38 | @MainActor
39 | func getMeals() async {
40 | do {
41 | meals = try await mealService.getMeals(for: selectedCategory)
42 | mealsSearchResults = meals
43 | filterMeals()
44 | state = meals.isEmpty ? .empty : .result
45 |
46 | } catch let error as MealError {
47 | self.error = error
48 | showError = true
49 | state = .error
50 |
51 | } catch {
52 | self.error = MealError.unknown
53 | showError = true
54 | state = .error
55 | }
56 | }
57 |
58 | /// Filters meals based on filter selection
59 | @MainActor
60 | func filterMeals() {
61 | switch mealFilter {
62 | case .alphabetical:
63 | mealsSearchResults.sort(by: { $0.name < $1.name })
64 | case .id:
65 | mealsSearchResults.sort(by: { $0.id < $1.id })
66 | }
67 | }
68 |
69 | /// Function is called as the user types to search for a meal
70 | ///
71 | /// User can search based on either name or id
72 | ///
73 | /// - Parameter searchText: String of what the user has currently typed
74 | @MainActor
75 | func searchMeals(with searchText: String) {
76 | if searchText.isEmpty {
77 | mealsSearchResults = meals
78 | } else {
79 | mealsSearchResults = meals.filter {
80 | $0.name.lowercased().contains(searchText.lowercased()) ||
81 | $0.id.contains(searchText) }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/CuisineUITests/CuisineUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineUITests.swift
3 | // CuisineUITests
4 | //
5 | // Created by Nolan Fuchs on 6/4/24.
6 | //
7 |
8 | import XCTest
9 | @testable import Cuisine
10 |
11 | final class CuisineUITests: XCTestCase {
12 |
13 | override func setUpWithError() throws {
14 | continueAfterFailure = false
15 | }
16 |
17 | /// Tests that a dessert recipe exists
18 | func test_Desert_Recipe_Exists() throws {
19 | let app = XCUIApplication()
20 | app.launch()
21 |
22 | let dessertCard = app.buttons["\(Meal.MOCK_DESSERT_MEAL.name) Recipe"]
23 |
24 | let maxSwipes = 12
25 | var currentSwipe = 0
26 |
27 | while !dessertCard.exists && currentSwipe < maxSwipes {
28 | app.swipeUp()
29 | currentSwipe += 1
30 | }
31 |
32 | XCTAssertTrue(dessertCard.exists)
33 | }
34 |
35 | /// Tests that the dessert category exists
36 | func test_Dessert_Category_Exists() throws {
37 | let app = XCUIApplication()
38 | app.launch()
39 |
40 | let dessertCategory = app.buttons["\(MealCategory.dessert.rawValue.capitalized) Meal Category"]
41 |
42 | XCTAssertTrue(dessertCategory.exists)
43 | }
44 |
45 | /// Test ensures that the menu filter exists
46 | /// Also ensures that the alphabetical filter exists
47 | func test_Alphabetical_Filter_Exists() throws {
48 | let app = XCUIApplication()
49 | app.launch()
50 |
51 | let filterMenu = app.buttons["Recipe Filter Menu"]
52 |
53 | XCTAssertTrue(filterMenu.exists)
54 |
55 | filterMenu.tapUnhittable()
56 |
57 | let alphabeticalFilterButton = app.buttons["\(MealFilter.alphabetical.rawValue.capitalized) Recipe Filter"]
58 |
59 | XCTAssertTrue(alphabeticalFilterButton.exists)
60 | }
61 |
62 | func testLaunchPerformance() throws {
63 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
64 | // This measures how long it takes to launch your application.
65 | measure(metrics: [XCTApplicationLaunchMetric()]) {
66 | XCUIApplication().launch()
67 | }
68 | }
69 | }
70 | }
71 |
72 | // Need this since there's a hittable UITest bug
73 | // Buttons that are enabled and exist cause an error on tap
74 | // Offset the tap to fix it
75 | extension XCUIElement {
76 | func tapUnhittable() {
77 | XCTContext.runActivity(named: "Tap \(self) by coordinate") { _ in
78 | coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/CuisineTests/Localization Tests/CuisineLocalizationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineLocalizationTests.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/12/24.
6 | //
7 |
8 | import XCTest
9 | @testable import Cuisine
10 |
11 | final class CuisineLocalizationTests: XCTestCase {
12 |
13 | let localizedLanguages = Bundle.main.localizations
14 |
15 | // Localized words to test against
16 | let recipe = "Recipe"
17 | let dessert = "Dessert"
18 | let instructions = "Instructions"
19 |
20 | /// Tests that the correct localized languages are available
21 | func test_Localized_Languages_Exist() {
22 | XCTAssertTrue(localizedLanguages.contains("en"))
23 | XCTAssertTrue(localizedLanguages.contains("sv"))
24 | }
25 |
26 | /// Tests base words for english localization
27 | func test_English_Localization() {
28 |
29 | let languageCode = "en"
30 |
31 | guard let bundlePath = Bundle.main.path(forResource: languageCode, ofType: "lproj") else {
32 | XCTFail("Could not load the localization bundle")
33 | return
34 | }
35 | guard let testBundle = Bundle(path: bundlePath) else {
36 | XCTFail("Could not load the English localization bundle")
37 | return
38 | }
39 |
40 | verifyLocalization(key: recipe, expected: "Recipe", bundle: testBundle)
41 | verifyLocalization(key: dessert, expected: "Dessert", bundle: testBundle)
42 | verifyLocalization(key: instructions, expected: "Instructions", bundle: testBundle)
43 | }
44 |
45 | /// Tests base words for swedish localization
46 | func test_Swedish_Localization() {
47 |
48 | let languageCode = "sv"
49 |
50 | guard let bundlePath = Bundle.main.path(forResource: languageCode, ofType: "lproj") else {
51 | XCTFail("Could not load the localization bundle")
52 | return
53 | }
54 | guard let testBundle = Bundle(path: bundlePath) else {
55 | XCTFail("Could not load the Swedish localization bundle")
56 | return
57 | }
58 |
59 | verifyLocalization(key: recipe, expected: "Recept", bundle: testBundle)
60 | verifyLocalization(key: dessert, expected: "Öken", bundle: testBundle)
61 | verifyLocalization(key: instructions, expected: "Instruktioner", bundle: testBundle)
62 | }
63 |
64 | /// Compares key to expeted value within the localization bundle
65 | private func verifyLocalization(key: String, expected: String, bundle: Bundle) {
66 | let localizedString = NSLocalizedString(key, bundle: bundle, comment: "")
67 | XCTAssertEqual(localizedString, expected, "Localization for key '\(key)' did not match. Expected: '\(expected)', got: '\(localizedString)'")
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/CuisineTests/Network Tests/MockDataJSON.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockDataJSON.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | struct MockDataJSON {
9 |
10 | static var mockMealsSuccessData =
11 | """
12 | {
13 | "meals": [
14 | {
15 | "strMeal": "Battenberg Cake",
16 | "strMealThumb": "https://www.themealdb.com/images/media/meals/ywwrsp1511720277.jpg",
17 | "idMeal": "52894"
18 | }
19 | ]
20 | }
21 | """
22 |
23 | static var mockMealsFailedData =
24 | """
25 | {
26 | "meals": [
27 | {
28 | "strMeal": "Battenberg Cake",
29 | "strMealThumb": "https://www.themealdb.com/images/media/meals/ywwrsp1511720277.jpg",
30 | }
31 | ]
32 | }
33 | """
34 |
35 | static var mockMealDetailsData =
36 | """
37 | {
38 | "meals": [
39 | {
40 | "idMeal": "52894",
41 | "strMeal": "Battenberg Cake",
42 | "strDrinkAlternate": null,
43 | "strCategory": "Dessert",
44 | "strArea": "British",
45 | "strMealThumb": "https://www.themealdb.com/images/media/meals/ywwrsp1511720277.jpg",
46 | "strTags": "Cake,Sweet",
47 | "strYoutube": "https://www.youtube.com/watch?v=aB41Q7kDZQ0",
48 | "strIngredient1": "Butter",
49 | "strIngredient2": "Caster Sugar",
50 | "strIngredient3": "Self-raising Flour",
51 | "strIngredient4": "Almonds",
52 | "strIngredient5": "Baking Powder",
53 | "strIngredient6": "Eggs",
54 | "strIngredient7": "Vanilla Extract",
55 | "strIngredient8": "Almond Extract",
56 | "strIngredient9": "Butter",
57 | "strIngredient10": "Caster Sugar",
58 | "strIngredient11": "Self-raising Flour",
59 | "strIngredient12": "Almonds",
60 | "strIngredient13": "Baking Powder",
61 | "strIngredient14": "Eggs",
62 | "strIngredient15": "Vanilla Extract",
63 | "strIngredient16": "Almond Extract",
64 | "strIngredient17": "Pink Food Colouring",
65 | "strIngredient18": "Apricot",
66 | "strIngredient19": "Marzipan",
67 | "strIngredient20": "Icing Sugar",
68 | "strMeasure1": "175g",
69 | "strMeasure2": "175g",
70 | "strMeasure3": "140g",
71 | "strMeasure4": "50g",
72 | "strMeasure5": "½ tsp",
73 | "strMeasure6": "3 Medium",
74 | "strMeasure7": "½ tsp",
75 | "strMeasure8": "¼ teaspoon",
76 | "strMeasure9": "175g",
77 | "strMeasure10": "175g",
78 | "strMeasure11": "140g",
79 | "strMeasure12": "50g",
80 | "strMeasure13": "½ tsp",
81 | "strMeasure14": "3 Medium",
82 | "strMeasure15": "½ tsp",
83 | "strMeasure16": "¼ teaspoon",
84 | "strMeasure17": "½ tsp",
85 | "strMeasure18": "200g",
86 | "strMeasure19": "1kg",
87 | "strMeasure20": "Dusting",
88 | "strSource": "https://www.bbcgoodfood.com/recipes/1120657/battenberg-cake",
89 | "strImageSource": null,
90 | "strCreativeCommonsConfirmed": null,
91 | "dateModified": null
92 | }
93 | ]
94 | }
95 | """
96 | }
97 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/Recipe Details/RecipeDetailsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RecipeDetailsView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct RecipeDetailsView: View {
11 |
12 | let meal: Meal
13 |
14 | @StateObject var viewModel = RecipeDetailsViewModel()
15 |
16 | var body: some View {
17 |
18 | // Can use ScrollView to boost performance
19 | // Animation masking works better for a list
20 | List {
21 |
22 | Section {
23 | RecipeDetailsHeaderView(meal: meal)
24 | .listRowSeparator(.hidden)
25 | .listSectionSeparator(.hidden)
26 | }
27 |
28 | switch viewModel.state {
29 | case .loading:
30 | Section {
31 | SkeletonDetailsView()
32 | .listSectionSeparator(.hidden)
33 | .listRowInsets(.zero)
34 | }
35 |
36 | case .result:
37 | if let mealDetail = viewModel.mealDetail {
38 |
39 | Section {
40 | RecipeDetailsAboutView(mealDetail: mealDetail)
41 | } header: {
42 | Text("About")
43 | .sectionTitle()
44 | }
45 | .listSectionSeparator(.hidden)
46 | .listRowInsets(.zero)
47 |
48 | Section {
49 | RecipeDetailsIngredientsView(mealDetail: mealDetail)
50 | } header: {
51 | Text("Ingredients")
52 | .sectionTitle()
53 | }
54 | .listSectionSeparator(.hidden)
55 | .listRowInsets(.zero)
56 |
57 | if let instructions = mealDetail.instructions {
58 | Section {
59 | RecipeDetailsInstructionsView(instructions: instructions)
60 | } header: {
61 | Text("Instructions")
62 | .sectionTitle()
63 | }
64 | .listSectionSeparator(.hidden)
65 | .listRowInsets(.zero)
66 | }
67 |
68 | if let tags = mealDetail.tags {
69 | Section {
70 | RecipeDetailsTagsView(tags: tags)
71 | } header: {
72 | Text("Tags")
73 | .sectionTitle()
74 | }
75 | .listSectionSeparator(.hidden)
76 | .listRowInsets(.zero)
77 | }
78 | }
79 |
80 | case .empty, .error:
81 | Section {
82 | EmptyDetailsView()
83 | .listRowSeparator(.hidden)
84 | }
85 | }
86 | }
87 | .listStyle(.plain)
88 | .navigationTitle("Recipe")
89 | .navigationBarTitleDisplayMode(.inline)
90 |
91 | .task {
92 | guard viewModel.state == .loading else { return }
93 | await viewModel.getMealDetail(for: meal)
94 | }
95 |
96 | .alert(viewModel.mealError?.title ?? MealError.unknown.title, isPresented: $viewModel.showMealError) {
97 | Button("Ok") {
98 | viewModel.mealError = nil
99 | }
100 | } message: {
101 | Text(viewModel.mealError?.message ?? MealError.unknown.message)
102 | }
103 | }
104 | }
105 |
106 | #Preview {
107 | NavigationView {
108 | RecipeDetailsView(meal: .MOCK_MEALS[0], viewModel: RecipeDetailsViewModel())
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/Cuisine/Main/Views/MainView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainView.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/4/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MainView: View {
11 |
12 | @StateObject private var viewModel = MainViewModel()
13 |
14 | @State private var searchText = ""
15 |
16 | var body: some View {
17 |
18 | // Navigation View (NavigationStack starts from iOS 16)
19 | NavigationView {
20 |
21 | // Using a list for the cell reusability aspect
22 | // Note this is only accessible through List, not ScrollView or LazyVStack
23 | List {
24 |
25 | Section {
26 | MealCategoryView(category: $viewModel.selectedCategory)
27 | }
28 |
29 | switch viewModel.state {
30 | case .loading:
31 | Section {
32 | SkeletonMealsView()
33 | .listRowSeparator(.hidden)
34 | }
35 |
36 | case .result:
37 | Section {
38 | ForEach(viewModel.mealsSearchResults) { meal in
39 | NavigationLink {
40 | RecipeDetailsView(meal: meal)
41 | } label: {
42 | RecipeView(meal: meal)
43 | }
44 | .accessibilityIdentifier("\(meal.name) Recipe")
45 | }
46 | .listRowSeparator(.hidden)
47 | } header: {
48 | HStack {
49 | Text("Recipes")
50 | Text(viewModel.mealsSearchResults.count, format: .number)
51 | .roundedRectBackground()
52 | }
53 | .sectionTitle()
54 | }
55 |
56 | case .empty, .error :
57 | Section {
58 | EmptyMealsView()
59 | .padding(.top)
60 | .listRowSeparator(.hidden)
61 | }
62 | }
63 | }
64 | .listStyle(.plain)
65 | .navigationTitle("Cuisine")
66 | .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
67 |
68 | .toolbar {
69 | MealFilterButton(mealFilter: $viewModel.mealFilter)
70 | }
71 |
72 | .task {
73 | guard viewModel.state == .loading else { return }
74 | await viewModel.getMeals()
75 | }
76 |
77 | .refreshable {
78 | await viewModel.getMeals()
79 | }
80 |
81 | // Search meals from the user's input
82 | .onChange(of: searchText) { value in
83 | viewModel.searchMeals(with: value)
84 | }
85 |
86 | // Changing meal category retrieves new meals
87 | .onChange(of: viewModel.selectedCategory) { value in
88 | Task {
89 | await viewModel.getMeals()
90 | }
91 | }
92 |
93 | // Sorting the recipes from toolbar filter selection
94 | .onChange(of: viewModel.mealFilter) { value in
95 | viewModel.filterMeals()
96 | }
97 |
98 | .alert(viewModel.error?.title ?? MealError.unknown.title, isPresented: $viewModel.showError) {
99 | Button("Ok") {
100 | viewModel.error = nil
101 | }
102 | } message: {
103 | Text(viewModel.error?.message ?? MealError.unknown.message)
104 | }
105 | }
106 | }
107 | }
108 |
109 | #Preview {
110 | MainView()
111 | }
112 |
--------------------------------------------------------------------------------
/Cuisine.xcodeproj/xcshareddata/xcschemes/Cuisine.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
36 |
42 |
43 |
44 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
69 |
75 |
76 |
77 |
78 |
84 |
86 |
92 |
93 |
94 |
95 |
97 |
98 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/CuisineTests/Network Tests/CuisineNetworkTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CuisineNetworkTests.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/11/24.
6 | //
7 |
8 | import XCTest
9 | @testable import Cuisine
10 |
11 | final class CuisineNetworkTests: XCTestCase {
12 |
13 | var urlSession: URLSession!
14 |
15 | var network: NetworkProtocol!
16 |
17 | override func setUp() {
18 | let config = URLSessionConfiguration.ephemeral
19 | config.protocolClasses = [MockURLProtocol.self]
20 | urlSession = URLSession(configuration: config)
21 | network = Network(urlSession: urlSession)
22 | }
23 |
24 | /// Tests the the success of the network fetch method
25 | func test_Fetch_Meals_Success() async throws {
26 |
27 | guard let url = FetchAPI.meal(category: .dessert).url else { throw URLError(.badURL) }
28 |
29 | guard let response = HTTPURLResponse(url: url,
30 | statusCode: 200,
31 | httpVersion: nil,
32 | headerFields: ["Content-Type": "application/json"]) else { throw URLError(.badServerResponse) }
33 |
34 | MockURLProtocol.requestHandler = { request in
35 | guard let mockData = MockDataJSON.mockMealsSuccessData.data(using: .utf8) else { throw URLError(.cannotDecodeRawData) }
36 | return (mockData, response)
37 | }
38 |
39 | let data: MealsResponse = try await network.fetch(url)
40 |
41 | XCTAssertEqual(data.meals.first?.name, "Battenberg Cake")
42 | XCTAssertEqual(data.meals.first?.thumbnailUrl, URL(string: "https://www.themealdb.com/images/media/meals/ywwrsp1511720277.jpg"))
43 | XCTAssertEqual(data.meals.first?.id, "52894")
44 | }
45 |
46 | /// Tests the the success of the network fetch method
47 | func test_Fetch_Meal_Details_Success() async throws {
48 |
49 | guard let url = FetchAPI.mealDetails(meal: Meal.MOCK_MEAL).url else { throw URLError(.badURL) }
50 |
51 | guard let response = HTTPURLResponse(url: url,
52 | statusCode: 200,
53 | httpVersion: nil,
54 | headerFields: ["Content-Type": "application/json"]) else { throw URLError(.badServerResponse) }
55 |
56 | MockURLProtocol.requestHandler = { request in
57 | guard let mockData = MockDataJSON.mockMealDetailsData.data(using: .utf8) else { throw URLError(.cannotDecodeRawData) }
58 | return (mockData, response)
59 | }
60 |
61 | let data: MealDetailsResponse = try await network.fetch(url)
62 |
63 | XCTAssertEqual(data.meals.first?.name, "Battenberg Cake")
64 | XCTAssertEqual(data.meals.first?.area, "British")
65 | XCTAssertEqual(data.meals.first?.ingredientNine, "Butter")
66 | XCTAssertEqual(data.meals.first?.id, "52894")
67 | }
68 |
69 | /// Tests the the json failure of the network fetch method
70 | func test_Fetch_Data_Fail() async throws {
71 |
72 | guard let url = FetchAPI.meal(category: .dessert).url else { throw URLError(.badURL) }
73 |
74 | guard let response = HTTPURLResponse(url: url,
75 | statusCode: 200,
76 | httpVersion: nil,
77 | headerFields: ["Content-Type": "application/json"]) else { throw URLError(.badServerResponse) }
78 |
79 | MockURLProtocol.requestHandler = { request in
80 | guard let mockData = MockDataJSON.mockMealsFailedData.data(using: .utf8) else { throw URLError(.cannotDecodeRawData) }
81 | return (mockData, response)
82 | }
83 |
84 | let expectation = expectation(description: "Incorrect JSON Format Error")
85 |
86 | do {
87 | let _: MealsResponse = try await network.fetch(url)
88 | XCTFail("The test should throw an unable to decode error.")
89 | expectation.fulfill()
90 | } catch let error as DecodingError {
91 | expectation.fulfill()
92 | } catch {
93 | XCTFail("The test should throw an unable to decode error.")
94 | expectation.fulfill()
95 | }
96 |
97 | await fulfillment(of: [expectation])
98 | }
99 |
100 | /// Tests the the json failure of the network fetch method
101 | func test_Fetch_Server_Fail() async throws {
102 |
103 | guard let url = FetchAPI.meal(category: .dessert).url else { throw URLError(.badURL) }
104 |
105 | guard let response = HTTPURLResponse(url: url,
106 | statusCode: 504,
107 | httpVersion: nil,
108 | headerFields: ["Content-Type": "application/json"]) else { throw URLError(.badServerResponse) }
109 |
110 | MockURLProtocol.requestHandler = { request in
111 | guard let mockData = MockDataJSON.mockMealsFailedData.data(using: .utf8) else { throw URLError(.cannotDecodeRawData) }
112 | return (mockData, response)
113 | }
114 |
115 | let expectation = expectation(description: "Incorrect JSON Format Error")
116 |
117 | do {
118 | let _: MealsResponse = try await network.fetch(url)
119 | XCTFail("The test should throw a status coder / server error.")
120 | expectation.fulfill()
121 | } catch let error as URLError {
122 | if error == URLError(.badServerResponse) {
123 | expectation.fulfill()
124 | } else {
125 | XCTFail("The test should throw a status coder / server error.")
126 | expectation.fulfill()
127 | }
128 | } catch {
129 | XCTFail("The test should throw a status coder / server error.")
130 | expectation.fulfill()
131 | }
132 |
133 | await fulfillment(of: [expectation])
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/Cuisine/Models/MealDetail.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealDetail.swift
3 | // Cuisine
4 | //
5 | // Created by Nolan Fuchs on 6/7/24.
6 | //
7 |
8 | import Foundation
9 |
10 | // Renamed the variables to make it a bit more clean
11 | struct MealDetail: Identifiable, Decodable {
12 |
13 | let id: String
14 | let name: String
15 |
16 | let drinkAlternate: String?
17 | let category: String?
18 | let area: String?
19 | let instructions: String?
20 | let thumbnailUrl: URL?
21 | let tags: String?
22 | let youtubeUrl: String?
23 |
24 | let ingredientOne: String?
25 | let ingredientTwo: String?
26 | let ingredientThree: String?
27 | let ingredientFour: String?
28 | let ingredientFive: String?
29 | let ingredientSix: String?
30 | let ingredientSeven: String?
31 | let ingredientEight: String?
32 | let ingredientNine: String?
33 | let ingredientTen: String?
34 | let ingredientEleven: String?
35 | let ingredientTwelve: String?
36 | let ingredientThirteen: String?
37 | let ingredientFourteen: String?
38 | let ingredientFifteen: String?
39 | let ingredientSixteen: String?
40 | let ingredientSeventeen: String?
41 | let ingredientEighteen: String?
42 | let ingredientNineteen: String?
43 | let ingredientTwenty: String?
44 |
45 | let measureOne: String?
46 | let measureTwo: String?
47 | let measureThree: String?
48 | let measureFour: String?
49 | let measureFive: String?
50 | let measureSix: String?
51 | let measureSeven: String?
52 | let measureEight: String?
53 | let measureNine: String?
54 | let measureTen: String?
55 | let measureEleven: String?
56 | let measureTwelve: String?
57 | let measureThirteen: String?
58 | let measureFourteen: String?
59 | let measureFifteen: String?
60 | let measureSixteen: String?
61 | let measureSeventeen: String?
62 | let measureEighteen: String?
63 | let measureNineteen: String?
64 | let measureTwenty: String?
65 |
66 | let sourceUrl: String?
67 | let sourceImageUrl: String?
68 | let creativeCommonsConfirmed: String?
69 | let dateModifed: String?
70 |
71 | // Since these are given as individual ingredients and measurements, its best to combine them
72 | // Could also do this within a decoder
73 | // This should also be combined server side rather than having to combine on the front end
74 | var ingredientsMap: [String:String] {
75 |
76 | var ingredientsMap: [String:String] = [:]
77 |
78 | func addToIngredientsMap(_ ingredient: String?, _ measurement: String?) {
79 | if let ingredient, !ingredient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let measurement, !measurement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
80 | ingredientsMap[ingredient] = measurement.trimmingCharacters(in: .whitespacesAndNewlines)
81 | }
82 | }
83 |
84 | addToIngredientsMap(ingredientOne, measureOne)
85 | addToIngredientsMap(ingredientTwo, measureTwo)
86 | addToIngredientsMap(ingredientThree, measureThree)
87 | addToIngredientsMap(ingredientFour, measureFour)
88 | addToIngredientsMap(ingredientFive, measureFive)
89 | addToIngredientsMap(ingredientSix, measureSix)
90 | addToIngredientsMap(ingredientSeven, measureSeven)
91 | addToIngredientsMap(ingredientEight, measureEight)
92 | addToIngredientsMap(ingredientNine, measureNine)
93 | addToIngredientsMap(ingredientTen, measureTen)
94 | addToIngredientsMap(ingredientEleven, measureEleven)
95 | addToIngredientsMap(ingredientTwelve, measureTwelve)
96 | addToIngredientsMap(ingredientThirteen, measureThirteen)
97 | addToIngredientsMap(ingredientFourteen, measureFourteen)
98 | addToIngredientsMap(ingredientFifteen, measureFifteen)
99 | addToIngredientsMap(ingredientSixteen, measureSixteen)
100 | addToIngredientsMap(ingredientSeventeen, measureSeventeen)
101 | addToIngredientsMap(ingredientEighteen, measureEighteen)
102 | addToIngredientsMap(ingredientNineteen, measureNineteen)
103 | addToIngredientsMap(ingredientTwenty, measureTwenty)
104 |
105 | return ingredientsMap
106 | }
107 |
108 | var aboutMap: [String:String] {
109 |
110 | var aboutMap: [String:String] = [:]
111 |
112 | if let category, !category.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
113 | aboutMap[MealDetailAbout.category.rawValue] = category
114 | }
115 |
116 | if let area, !area.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
117 | aboutMap[MealDetailAbout.origin.rawValue] = area
118 | }
119 |
120 | if let youtubeUrl, !youtubeUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
121 | aboutMap[MealDetailAbout.youtube.rawValue] = youtubeUrl
122 | }
123 |
124 | if let sourceUrl, !sourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
125 | aboutMap[MealDetailAbout.website.rawValue] = sourceUrl
126 | }
127 |
128 | aboutMap[MealDetailAbout.id.rawValue] = id
129 |
130 | return aboutMap
131 | }
132 |
133 | // Manual Coding Keys should be non-existent in newer projects
134 | // Synchronization is critical which reduces bugs and failure points
135 | // ---> using keyDecodingStrategies is preferred <----
136 | enum CodingKeys: String, CodingKey {
137 |
138 | case id = "idMeal"
139 | case name = "strMeal"
140 |
141 | case drinkAlternate = "strDrinkAlternate"
142 | case category = "strCategory"
143 | case area = "strArea"
144 | case instructions = "strInstructions"
145 | case thumbnailUrl = "strMealThumb"
146 | case tags = "strTags"
147 | case youtubeUrl = "strYoutube"
148 |
149 | case ingredientOne = "strIngredient1"
150 | case ingredientTwo = "strIngredient2"
151 | case ingredientThree = "strIngredient3"
152 | case ingredientFour = "strIngredient4"
153 | case ingredientFive = "strIngredient5"
154 | case ingredientSix = "strIngredient6"
155 | case ingredientSeven = "strIngredient7"
156 | case ingredientEight = "strIngredient8"
157 | case ingredientNine = "strIngredient9"
158 | case ingredientTen = "strIngredient10"
159 | case ingredientEleven = "strIngredient11"
160 | case ingredientTwelve = "strIngredient12"
161 | case ingredientThirteen = "strIngredient13"
162 | case ingredientFourteen = "strIngredient14"
163 | case ingredientFifteen = "strIngredient15"
164 | case ingredientSixteen = "strIngredient16"
165 | case ingredientSeventeen = "strIngredient17"
166 | case ingredientEighteen = "strIngredient18"
167 | case ingredientNineteen = "strIngredient19"
168 | case ingredientTwenty = "strIngredient20"
169 |
170 | case measureOne = "strMeasure1"
171 | case measureTwo = "strMeasure2"
172 | case measureThree = "strMeasure3"
173 | case measureFour = "strMeasure4"
174 | case measureFive = "strMeasure5"
175 | case measureSix = "strMeasure6"
176 | case measureSeven = "strMeasure7"
177 | case measureEight = "strMeasure8"
178 | case measureNine = "strMeasure9"
179 | case measureTen = "strMeasure10"
180 | case measureEleven = "strMeasure11"
181 | case measureTwelve = "strMeasure12"
182 | case measureThirteen = "strMeasure13"
183 | case measureFourteen = "strMeasure14"
184 | case measureFifteen = "strMeasure15"
185 | case measureSixteen = "strMeasure16"
186 | case measureSeventeen = "strMeasure17"
187 | case measureEighteen = "strMeasure18"
188 | case measureNineteen = "strMeasure19"
189 | case measureTwenty = "strMeasure20"
190 |
191 | case sourceUrl = "strSource"
192 | case sourceImageUrl = "strImageSource"
193 | case creativeCommonsConfirmed = "strCreativeCommonsConfirmed"
194 | case dateModifed = "dateModified"
195 | }
196 | }
197 |
198 | extension MealDetail {
199 |
200 | static var MOCK_MEAL_DETAIL: MealDetail {
201 | .init(id: "53005",
202 | name: "Strawberry Rhubarb Pie",
203 | drinkAlternate: nil,
204 | category: "Dessert",
205 | area: "British",
206 | instructions: "Pie Crust: In a food processor, place the flour, salt, and sugar and process until combined. Add the butter and process until the mixture resembles coarse\r\n\r\nmeal (about 15 seconds). Pour 1/4 cup (60 ml) water in a slow, steady stream, through the feed tube until the dough just holds together when pinched. If necessary, add more water. Do not process more than 30 seconds.\r\nTurn the dough onto your work surface and gather into a ball. Divide the dough in half, flattening each half into a disk, cover with plastic wrap, and refrigerate for about one hour before using. This will chill the butter and relax the gluten in the flour. \r\n\r\nAfter the dough has chilled sufficiently, remove one portion of the dough from the fridge and place it on a lightly floured surface. Roll the pastry into a 12 inch (30 cm) circle. (To prevent the pastry from sticking to the counter and to ensure uniform thickness, keep lifting up and turning the pastry a quarter turn as you roll (always roll from the center of the pastry outwards).) Fold the dough in half and gently transfer to a 9 inch (23 cm) pie pan. Brush off any excess flour and trim any overhanging pastry to an edge of 1/2 inch (1.5 cm). Refrigerate the pastry, covered with plastic wrap, while you make the filling. \r\n\r\nRemove the second round of pastry and roll it into a 13 inch (30 cm) circle. Using a pastry wheel or pizza cutter, cut the pastry into about 3/4 inch (2 cm) strips. Place the strips of pastry on a parchment paper-lined baking sheet, cover with plastic wrap, and place in the refrigerator for about 10 minutes. \r\n\r\nMake the Strawberry Rhubarb Filling: Place the cut strawberries and rhubarb in a large bowl. In a small bowl mix together the cornstarch, sugar, and ground cinnamon. \r\n\r\nRemove the chilled pie crust from the fridge. Sprinkle about 2 tablespoons of the sugar mixture over the bottom of the pastry crust. Add the remaining sugar mixture to the strawberries and rhubarb and gently toss to combine. Pour the fruit mixture into the prepared pie shell. Sprinkle the fruit with about 1 teaspoon of lemon juice and dot with 2 tablespoons of butter.\r\n\r\nRemove the lattice pastry from the refrigerator and, starting at the center with the longest strips and working outwards, place half the strips, spacing about 1 inch (2.5 cm) apart, on top of the filling. (Use the shortest pastry strips at the outer edges.) Then, gently fold back, about halfway, every other strip of pastry. Take another strip of pastry and place it perpendicular on top of the first strips of pastry. Unfold the bottom strips of pastry and then fold back the strips that weren't folded back the first time. Lay another strip of pastry perpendicular on top of the filling and then continue with the remaining strips. Trim the edges of the pastry strips, leaving a 1 inch (2.5 cm) overhang. Seal the edges of the pastry strips by folding them under the bottom pastry crust and flute the edges of the pastry. Brush the lattice pastry with milk and sprinkle with a little sugar. Cover and place in the refrigerator while you preheat the oven to 400 degrees F (205 degrees C) and place the oven rack in the lower third of the oven. Put a baking sheet, lined with aluminum foil, on the oven rack (to catch any spills.)\r\n\r\nPlace the pie plate on the hot baking sheet and bake the pie for about 35 minutes and then, if the edges of the pie are browning too much, cover with a foil ring. Continue to bake the pie for about another 10 minutes or until the crust is a golden brown color and the fruit juices begin to bubble.\r\n\r\nRemove the pie from the oven and place on a wire rack to cool for several hours. Serve at room temperature with softly whipped cream or vanilla ice cream. Leftovers can be stored in the refrigerator for about 3 days. Reheat before serving. This pie can be frozen.\r\n\r\nMakes one 9 inch (23 cm) pie.",
207 | thumbnailUrl: URL(string: "https://www.themealdb.com/images/media/meals/178z5o1585514569.jpg"),
208 | tags: "Pudding,Pie,Baking,Fruity,Glazed",
209 | youtubeUrl: "https://www.youtube.com/watch?v=tGw5Pwm4YA0",
210 | ingredientOne: "Flour",
211 | ingredientTwo: "Salt",
212 | ingredientThree: "Sugar",
213 | ingredientFour: "Butter",
214 | ingredientFive: "Water",
215 | ingredientSix: "Rhubarb",
216 | ingredientSeven: "Strawberries",
217 | ingredientEight: "Cornstarch",
218 | ingredientNine: "Sugar",
219 | ingredientTen: "Cinnamon",
220 | ingredientEleven: "Lemon Juice",
221 | ingredientTwelve: "Unsalted Butter",
222 | ingredientThirteen: "Milk",
223 | ingredientFourteen: "Sugar",
224 | ingredientFifteen: "",
225 | ingredientSixteen: "",
226 | ingredientSeventeen: "",
227 | ingredientEighteen: "",
228 | ingredientNineteen: "",
229 | ingredientTwenty: "",
230 | measureOne: "350g",
231 | measureTwo: "1 tsp ",
232 | measureThree: "2 tbs",
233 | measureFour: "1 cup ",
234 | measureFive: "1/2 cup ",
235 | measureSix: "450g",
236 | measureSeven: "450g",
237 | measureEight: "3 tbs",
238 | measureNine: "150g",
239 | measureTen: "1/4 tsp",
240 | measureEleven: "1 tsp ",
241 | measureTwelve: "2 tbs",
242 | measureThirteen: "2 tbs",
243 | measureFourteen: "Spinkling",
244 | measureFifteen: "",
245 | measureSixteen: "",
246 | measureSeventeen: "",
247 | measureEighteen: "",
248 | measureNineteen: "",
249 | measureTwenty: "",
250 | sourceUrl: "https://www.joyofbaking.com/StrawberryRhubarbPie.html",
251 | sourceImageUrl: nil,
252 | creativeCommonsConfirmed: nil,
253 | dateModifed: nil)
254 | }
255 | }
256 |
--------------------------------------------------------------------------------
/Cuisine/Localization/Localizable.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "%@ Recipe Filter" : {
5 | "localizations" : {
6 | "en" : {
7 | "stringUnit" : {
8 | "state" : "translated",
9 | "value" : "%@ Recipe Filter"
10 | }
11 | },
12 | "sv" : {
13 | "stringUnit" : {
14 | "state" : "translated",
15 | "value" : "%@ Receptfilter"
16 | }
17 | }
18 | }
19 | },
20 | "About" : {
21 | "localizations" : {
22 | "en" : {
23 | "stringUnit" : {
24 | "state" : "translated",
25 | "value" : "About"
26 | }
27 | },
28 | "sv" : {
29 | "stringUnit" : {
30 | "state" : "translated",
31 | "value" : "Handla om"
32 | }
33 | }
34 | }
35 | },
36 | "Alphabetical" : {
37 | "extractionState" : "manual",
38 | "localizations" : {
39 | "en" : {
40 | "stringUnit" : {
41 | "state" : "translated",
42 | "value" : "Alphabetical"
43 | }
44 | },
45 | "sv" : {
46 | "stringUnit" : {
47 | "state" : "translated",
48 | "value" : "Alfabetisk"
49 | }
50 | }
51 | }
52 | },
53 | "American" : {
54 | "extractionState" : "manual",
55 | "localizations" : {
56 | "en" : {
57 | "stringUnit" : {
58 | "state" : "translated",
59 | "value" : "American"
60 | }
61 | },
62 | "sv" : {
63 | "stringUnit" : {
64 | "state" : "translated",
65 | "value" : "Amerikansk"
66 | }
67 | }
68 | }
69 | },
70 | "Beef" : {
71 | "extractionState" : "manual",
72 | "localizations" : {
73 | "en" : {
74 | "stringUnit" : {
75 | "state" : "translated",
76 | "value" : "Beef"
77 | }
78 | },
79 | "sv" : {
80 | "stringUnit" : {
81 | "state" : "translated",
82 | "value" : "Nötkött"
83 | }
84 | }
85 | }
86 | },
87 | "Breakfast" : {
88 | "extractionState" : "manual",
89 | "localizations" : {
90 | "en" : {
91 | "stringUnit" : {
92 | "state" : "translated",
93 | "value" : "Breakfast"
94 | }
95 | },
96 | "sv" : {
97 | "stringUnit" : {
98 | "state" : "translated",
99 | "value" : "Frukost"
100 | }
101 | }
102 | }
103 | },
104 | "British" : {
105 | "extractionState" : "manual",
106 | "localizations" : {
107 | "en" : {
108 | "stringUnit" : {
109 | "state" : "translated",
110 | "value" : "British"
111 | }
112 | },
113 | "sv" : {
114 | "stringUnit" : {
115 | "state" : "translated",
116 | "value" : "Brittisk"
117 | }
118 | }
119 | }
120 | },
121 | "Canadian" : {
122 | "extractionState" : "manual",
123 | "localizations" : {
124 | "en" : {
125 | "stringUnit" : {
126 | "state" : "translated",
127 | "value" : "Canadian"
128 | }
129 | },
130 | "sv" : {
131 | "stringUnit" : {
132 | "state" : "translated",
133 | "value" : "Kanadensisk"
134 | }
135 | }
136 | }
137 | },
138 | "Categories" : {
139 | "localizations" : {
140 | "en" : {
141 | "stringUnit" : {
142 | "state" : "translated",
143 | "value" : "Categories"
144 | }
145 | },
146 | "sv" : {
147 | "stringUnit" : {
148 | "state" : "translated",
149 | "value" : "Kategorier"
150 | }
151 | }
152 | }
153 | },
154 | "Category" : {
155 | "extractionState" : "manual",
156 | "localizations" : {
157 | "en" : {
158 | "stringUnit" : {
159 | "state" : "translated",
160 | "value" : "Category"
161 | }
162 | },
163 | "sv" : {
164 | "stringUnit" : {
165 | "state" : "translated",
166 | "value" : "Kategori"
167 | }
168 | }
169 | }
170 | },
171 | "Chicken" : {
172 | "extractionState" : "manual",
173 | "localizations" : {
174 | "en" : {
175 | "stringUnit" : {
176 | "state" : "translated",
177 | "value" : "Chicken"
178 | }
179 | },
180 | "sv" : {
181 | "stringUnit" : {
182 | "state" : "translated",
183 | "value" : "Kyckling"
184 | }
185 | }
186 | }
187 | },
188 | "Chinese" : {
189 | "extractionState" : "manual",
190 | "localizations" : {
191 | "en" : {
192 | "stringUnit" : {
193 | "state" : "translated",
194 | "value" : "Chinese"
195 | }
196 | },
197 | "sv" : {
198 | "stringUnit" : {
199 | "state" : "translated",
200 | "value" : "Kinesiska"
201 | }
202 | }
203 | }
204 | },
205 | "Croatian" : {
206 | "extractionState" : "manual",
207 | "localizations" : {
208 | "en" : {
209 | "stringUnit" : {
210 | "state" : "translated",
211 | "value" : "Croatian"
212 | }
213 | },
214 | "sv" : {
215 | "stringUnit" : {
216 | "state" : "translated",
217 | "value" : "Kroatisk"
218 | }
219 | }
220 | }
221 | },
222 | "Cuisine" : {
223 | "localizations" : {
224 | "en" : {
225 | "stringUnit" : {
226 | "state" : "translated",
227 | "value" : "Cuisine"
228 | }
229 | },
230 | "sv" : {
231 | "stringUnit" : {
232 | "state" : "translated",
233 | "value" : "Kök"
234 | }
235 | }
236 | }
237 | },
238 | "Dessert" : {
239 | "extractionState" : "manual",
240 | "localizations" : {
241 | "en" : {
242 | "stringUnit" : {
243 | "state" : "translated",
244 | "value" : "Dessert"
245 | }
246 | },
247 | "sv" : {
248 | "stringUnit" : {
249 | "state" : "translated",
250 | "value" : "Öken"
251 | }
252 | }
253 | }
254 | },
255 | "Dutch" : {
256 | "extractionState" : "manual",
257 | "localizations" : {
258 | "en" : {
259 | "stringUnit" : {
260 | "state" : "translated",
261 | "value" : "Dutch"
262 | }
263 | },
264 | "sv" : {
265 | "stringUnit" : {
266 | "state" : "translated",
267 | "value" : "Holländska"
268 | }
269 | }
270 | }
271 | },
272 | "Egyptian" : {
273 | "extractionState" : "manual",
274 | "localizations" : {
275 | "en" : {
276 | "stringUnit" : {
277 | "state" : "translated",
278 | "value" : "Egyptian"
279 | }
280 | },
281 | "sv" : {
282 | "stringUnit" : {
283 | "state" : "translated",
284 | "value" : "Egyptisk"
285 | }
286 | }
287 | }
288 | },
289 | "Filipino" : {
290 | "extractionState" : "manual",
291 | "localizations" : {
292 | "en" : {
293 | "stringUnit" : {
294 | "state" : "translated",
295 | "value" : "Filipino"
296 | }
297 | },
298 | "sv" : {
299 | "stringUnit" : {
300 | "state" : "translated",
301 | "value" : "Filippinare"
302 | }
303 | }
304 | }
305 | },
306 | "French" : {
307 | "extractionState" : "manual",
308 | "localizations" : {
309 | "en" : {
310 | "stringUnit" : {
311 | "state" : "translated",
312 | "value" : "French"
313 | }
314 | },
315 | "sv" : {
316 | "stringUnit" : {
317 | "state" : "translated",
318 | "value" : "Franska"
319 | }
320 | }
321 | }
322 | },
323 | "German" : {
324 | "extractionState" : "manual",
325 | "localizations" : {
326 | "en" : {
327 | "stringUnit" : {
328 | "state" : "translated",
329 | "value" : "German"
330 | }
331 | },
332 | "sv" : {
333 | "stringUnit" : {
334 | "state" : "translated",
335 | "value" : "Tysk"
336 | }
337 | }
338 | }
339 | },
340 | "Goat" : {
341 | "extractionState" : "manual",
342 | "localizations" : {
343 | "en" : {
344 | "stringUnit" : {
345 | "state" : "translated",
346 | "value" : "Goat"
347 | }
348 | },
349 | "sv" : {
350 | "stringUnit" : {
351 | "state" : "translated",
352 | "value" : "Get"
353 | }
354 | }
355 | }
356 | },
357 | "Greek" : {
358 | "extractionState" : "manual",
359 | "localizations" : {
360 | "en" : {
361 | "stringUnit" : {
362 | "state" : "translated",
363 | "value" : "Greek"
364 | }
365 | },
366 | "sv" : {
367 | "stringUnit" : {
368 | "state" : "translated",
369 | "value" : "Grekisk"
370 | }
371 | }
372 | }
373 | },
374 | "Id: %@" : {
375 | "localizations" : {
376 | "en" : {
377 | "stringUnit" : {
378 | "state" : "translated",
379 | "value" : "Id: %@"
380 | }
381 | },
382 | "sv" : {
383 | "stringUnit" : {
384 | "state" : "translated",
385 | "value" : "Id: %@"
386 | }
387 | }
388 | }
389 | },
390 | "Indian" : {
391 | "extractionState" : "manual",
392 | "localizations" : {
393 | "en" : {
394 | "stringUnit" : {
395 | "state" : "translated",
396 | "value" : "Indian"
397 | }
398 | },
399 | "sv" : {
400 | "stringUnit" : {
401 | "state" : "translated",
402 | "value" : "Indiska"
403 | }
404 | }
405 | }
406 | },
407 | "Ingredients" : {
408 | "localizations" : {
409 | "en" : {
410 | "stringUnit" : {
411 | "state" : "translated",
412 | "value" : "Ingredients"
413 | }
414 | },
415 | "sv" : {
416 | "stringUnit" : {
417 | "state" : "translated",
418 | "value" : "Ingredienser"
419 | }
420 | }
421 | }
422 | },
423 | "Instructions" : {
424 | "localizations" : {
425 | "en" : {
426 | "stringUnit" : {
427 | "state" : "translated",
428 | "value" : "Instructions"
429 | }
430 | },
431 | "sv" : {
432 | "stringUnit" : {
433 | "state" : "translated",
434 | "value" : "Instruktioner"
435 | }
436 | }
437 | }
438 | },
439 | "Irish" : {
440 | "extractionState" : "manual",
441 | "localizations" : {
442 | "en" : {
443 | "stringUnit" : {
444 | "state" : "translated",
445 | "value" : "Irish"
446 | }
447 | },
448 | "sv" : {
449 | "stringUnit" : {
450 | "state" : "translated",
451 | "value" : "Irländska"
452 | }
453 | }
454 | }
455 | },
456 | "Italian" : {
457 | "extractionState" : "manual",
458 | "localizations" : {
459 | "en" : {
460 | "stringUnit" : {
461 | "state" : "translated",
462 | "value" : "Italian"
463 | }
464 | },
465 | "sv" : {
466 | "stringUnit" : {
467 | "state" : "translated",
468 | "value" : "Italienska"
469 | }
470 | }
471 | }
472 | },
473 | "Jamaican" : {
474 | "extractionState" : "manual",
475 | "localizations" : {
476 | "en" : {
477 | "stringUnit" : {
478 | "state" : "translated",
479 | "value" : "Jamaican"
480 | }
481 | },
482 | "sv" : {
483 | "stringUnit" : {
484 | "state" : "translated",
485 | "value" : "Jamaican"
486 | }
487 | }
488 | }
489 | },
490 | "Japanese" : {
491 | "extractionState" : "manual",
492 | "localizations" : {
493 | "en" : {
494 | "stringUnit" : {
495 | "state" : "translated",
496 | "value" : "Japanese"
497 | }
498 | },
499 | "sv" : {
500 | "stringUnit" : {
501 | "state" : "translated",
502 | "value" : "Japanska"
503 | }
504 | }
505 | }
506 | },
507 | "Kenyan" : {
508 | "extractionState" : "manual",
509 | "localizations" : {
510 | "en" : {
511 | "stringUnit" : {
512 | "state" : "translated",
513 | "value" : "Kenyan"
514 | }
515 | },
516 | "sv" : {
517 | "stringUnit" : {
518 | "state" : "translated",
519 | "value" : "Kenyan"
520 | }
521 | }
522 | }
523 | },
524 | "Lamb" : {
525 | "extractionState" : "manual",
526 | "localizations" : {
527 | "en" : {
528 | "stringUnit" : {
529 | "state" : "translated",
530 | "value" : "Lamb"
531 | }
532 | },
533 | "sv" : {
534 | "stringUnit" : {
535 | "state" : "translated",
536 | "value" : "Lamm"
537 | }
538 | }
539 | }
540 | },
541 | "Lorem Impsum" : {
542 | "localizations" : {
543 | "en" : {
544 | "stringUnit" : {
545 | "state" : "translated",
546 | "value" : "Lorem Impsum"
547 | }
548 | },
549 | "sv" : {
550 | "stringUnit" : {
551 | "state" : "translated",
552 | "value" : "Lorem Impsum"
553 | }
554 | }
555 | }
556 | },
557 | "Lorem ipsum" : {
558 | "localizations" : {
559 | "en" : {
560 | "stringUnit" : {
561 | "state" : "translated",
562 | "value" : "Lorem ipsum"
563 | }
564 | },
565 | "sv" : {
566 | "stringUnit" : {
567 | "state" : "translated",
568 | "value" : "Lorem ipsum"
569 | }
570 | }
571 | }
572 | },
573 | "Lorem Ipsum" : {
574 | "localizations" : {
575 | "en" : {
576 | "stringUnit" : {
577 | "state" : "translated",
578 | "value" : "Lorem Ipsum"
579 | }
580 | },
581 | "sv" : {
582 | "stringUnit" : {
583 | "state" : "translated",
584 | "value" : "Lorem Ipsum"
585 | }
586 | }
587 | }
588 | },
589 | "Lorem ipsum dolor sit" : {
590 | "localizations" : {
591 | "en" : {
592 | "stringUnit" : {
593 | "state" : "translated",
594 | "value" : "Lorem ipsum dolor sit"
595 | }
596 | },
597 | "sv" : {
598 | "stringUnit" : {
599 | "state" : "translated",
600 | "value" : "Lorem ipsum dolor sit"
601 | }
602 | }
603 | }
604 | },
605 | "Malaysian" : {
606 | "extractionState" : "manual",
607 | "localizations" : {
608 | "en" : {
609 | "stringUnit" : {
610 | "state" : "translated",
611 | "value" : "Malaysian"
612 | }
613 | },
614 | "sv" : {
615 | "stringUnit" : {
616 | "state" : "translated",
617 | "value" : "Malaysisk"
618 | }
619 | }
620 | }
621 | },
622 | "Mexican" : {
623 | "extractionState" : "manual",
624 | "localizations" : {
625 | "en" : {
626 | "stringUnit" : {
627 | "state" : "translated",
628 | "value" : "Mexican"
629 | }
630 | },
631 | "sv" : {
632 | "stringUnit" : {
633 | "state" : "translated",
634 | "value" : "Mexikansk"
635 | }
636 | }
637 | }
638 | },
639 | "Miscellaneous" : {
640 | "extractionState" : "manual",
641 | "localizations" : {
642 | "en" : {
643 | "stringUnit" : {
644 | "state" : "translated",
645 | "value" : "Miscellaneous"
646 | }
647 | },
648 | "sv" : {
649 | "stringUnit" : {
650 | "state" : "translated",
651 | "value" : "Diverse"
652 | }
653 | }
654 | }
655 | },
656 | "No Recipes Found" : {
657 | "localizations" : {
658 | "en" : {
659 | "stringUnit" : {
660 | "state" : "translated",
661 | "value" : "No Recipes Found"
662 | }
663 | },
664 | "sv" : {
665 | "stringUnit" : {
666 | "state" : "translated",
667 | "value" : "Inga Recept Hittades"
668 | }
669 | }
670 | }
671 | },
672 | "No recipes were found. Try another category." : {
673 | "localizations" : {
674 | "en" : {
675 | "stringUnit" : {
676 | "state" : "translated",
677 | "value" : "No recipes were found. Try another category."
678 | }
679 | },
680 | "sv" : {
681 | "stringUnit" : {
682 | "state" : "translated",
683 | "value" : "Inga recept hittades. Prova en annan kategori."
684 | }
685 | }
686 | }
687 | },
688 | "Ok" : {
689 | "localizations" : {
690 | "en" : {
691 | "stringUnit" : {
692 | "state" : "translated",
693 | "value" : "Ok"
694 | }
695 | },
696 | "sv" : {
697 | "stringUnit" : {
698 | "state" : "translated",
699 | "value" : "Ok"
700 | }
701 | }
702 | }
703 | },
704 | "Origin" : {
705 | "extractionState" : "manual",
706 | "localizations" : {
707 | "en" : {
708 | "stringUnit" : {
709 | "state" : "translated",
710 | "value" : "Origin"
711 | }
712 | },
713 | "sv" : {
714 | "stringUnit" : {
715 | "state" : "translated",
716 | "value" : "Ursprung"
717 | }
718 | }
719 | }
720 | },
721 | "Pasta" : {
722 | "extractionState" : "manual",
723 | "localizations" : {
724 | "en" : {
725 | "stringUnit" : {
726 | "state" : "translated",
727 | "value" : "Pasta"
728 | }
729 | },
730 | "sv" : {
731 | "stringUnit" : {
732 | "state" : "translated",
733 | "value" : "Pasta"
734 | }
735 | }
736 | }
737 | },
738 | "Polish" : {
739 | "extractionState" : "manual",
740 | "localizations" : {
741 | "en" : {
742 | "stringUnit" : {
743 | "state" : "translated",
744 | "value" : "Polish"
745 | }
746 | },
747 | "sv" : {
748 | "stringUnit" : {
749 | "state" : "translated",
750 | "value" : "Putsa"
751 | }
752 | }
753 | }
754 | },
755 | "Pork" : {
756 | "extractionState" : "manual",
757 | "localizations" : {
758 | "en" : {
759 | "stringUnit" : {
760 | "state" : "translated",
761 | "value" : "Pork"
762 | }
763 | },
764 | "sv" : {
765 | "stringUnit" : {
766 | "state" : "translated",
767 | "value" : "Fläsk"
768 | }
769 | }
770 | }
771 | },
772 | "Portuguese" : {
773 | "extractionState" : "manual",
774 | "localizations" : {
775 | "en" : {
776 | "stringUnit" : {
777 | "state" : "translated",
778 | "value" : "Portuguese"
779 | }
780 | },
781 | "sv" : {
782 | "stringUnit" : {
783 | "state" : "translated",
784 | "value" : "Portugisiska"
785 | }
786 | }
787 | }
788 | },
789 | "Recipe" : {
790 | "localizations" : {
791 | "en" : {
792 | "stringUnit" : {
793 | "state" : "translated",
794 | "value" : "Recipe"
795 | }
796 | },
797 | "sv" : {
798 | "stringUnit" : {
799 | "state" : "translated",
800 | "value" : "Recept"
801 | }
802 | }
803 | }
804 | },
805 | "Recipe Details Error" : {
806 | "localizations" : {
807 | "en" : {
808 | "stringUnit" : {
809 | "state" : "translated",
810 | "value" : "Recipe Details Error"
811 | }
812 | },
813 | "sv" : {
814 | "stringUnit" : {
815 | "state" : "translated",
816 | "value" : "Receptdetaljer Fel"
817 | }
818 | }
819 | }
820 | },
821 | "Recipe Filter Menu" : {
822 | "localizations" : {
823 | "en" : {
824 | "stringUnit" : {
825 | "state" : "translated",
826 | "value" : "Recipe Filter Menu"
827 | }
828 | },
829 | "sv" : {
830 | "stringUnit" : {
831 | "state" : "translated",
832 | "value" : "Receptfiltermeny"
833 | }
834 | }
835 | }
836 | },
837 | "Recipes" : {
838 | "localizations" : {
839 | "en" : {
840 | "stringUnit" : {
841 | "state" : "translated",
842 | "value" : "Recipes"
843 | }
844 | },
845 | "sv" : {
846 | "stringUnit" : {
847 | "state" : "translated",
848 | "value" : "Recept"
849 | }
850 | }
851 | }
852 | },
853 | "Russian" : {
854 | "extractionState" : "manual",
855 | "localizations" : {
856 | "en" : {
857 | "stringUnit" : {
858 | "state" : "translated",
859 | "value" : "Russian"
860 | }
861 | },
862 | "sv" : {
863 | "stringUnit" : {
864 | "state" : "translated",
865 | "value" : "Ryska"
866 | }
867 | }
868 | }
869 | },
870 | "Seafood" : {
871 | "extractionState" : "manual",
872 | "localizations" : {
873 | "en" : {
874 | "stringUnit" : {
875 | "state" : "translated",
876 | "value" : "Seafood"
877 | }
878 | },
879 | "sv" : {
880 | "stringUnit" : {
881 | "state" : "translated",
882 | "value" : "Skaldjur"
883 | }
884 | }
885 | }
886 | },
887 | "Side" : {
888 | "extractionState" : "manual",
889 | "localizations" : {
890 | "en" : {
891 | "stringUnit" : {
892 | "state" : "translated",
893 | "value" : "Side"
894 | }
895 | },
896 | "sv" : {
897 | "stringUnit" : {
898 | "state" : "translated",
899 | "value" : "Sida"
900 | }
901 | }
902 | }
903 | },
904 | "Spanish" : {
905 | "extractionState" : "manual",
906 | "localizations" : {
907 | "en" : {
908 | "stringUnit" : {
909 | "state" : "translated",
910 | "value" : "Spanish"
911 | }
912 | },
913 | "sv" : {
914 | "stringUnit" : {
915 | "state" : "translated",
916 | "value" : "Spanska"
917 | }
918 | }
919 | }
920 | },
921 | "Starter" : {
922 | "extractionState" : "manual",
923 | "localizations" : {
924 | "en" : {
925 | "stringUnit" : {
926 | "state" : "translated",
927 | "value" : "Starter"
928 | }
929 | },
930 | "sv" : {
931 | "stringUnit" : {
932 | "state" : "translated",
933 | "value" : "Förrätt"
934 | }
935 | }
936 | }
937 | },
938 | "Tags" : {
939 | "localizations" : {
940 | "en" : {
941 | "stringUnit" : {
942 | "state" : "translated",
943 | "value" : "Tags"
944 | }
945 | },
946 | "sv" : {
947 | "stringUnit" : {
948 | "state" : "translated",
949 | "value" : "Taggar"
950 | }
951 | }
952 | }
953 | },
954 | "Thai" : {
955 | "extractionState" : "manual",
956 | "localizations" : {
957 | "en" : {
958 | "stringUnit" : {
959 | "state" : "translated",
960 | "value" : "Thai"
961 | }
962 | },
963 | "sv" : {
964 | "stringUnit" : {
965 | "state" : "translated",
966 | "value" : "Thai"
967 | }
968 | }
969 | }
970 | },
971 | "Turkish" : {
972 | "extractionState" : "manual",
973 | "localizations" : {
974 | "en" : {
975 | "stringUnit" : {
976 | "state" : "translated",
977 | "value" : "Turkish"
978 | }
979 | },
980 | "sv" : {
981 | "stringUnit" : {
982 | "state" : "translated",
983 | "value" : "Turkiska"
984 | }
985 | }
986 | }
987 | },
988 | "Unable to load recipe. Please try again." : {
989 | "localizations" : {
990 | "en" : {
991 | "stringUnit" : {
992 | "state" : "translated",
993 | "value" : "Unable to load recipe. Please try again."
994 | }
995 | },
996 | "sv" : {
997 | "stringUnit" : {
998 | "state" : "translated",
999 | "value" : "Det gick inte att ladda receptet. Var god försök igen."
1000 | }
1001 | }
1002 | }
1003 | },
1004 | "Unknown" : {
1005 | "extractionState" : "manual",
1006 | "localizations" : {
1007 | "en" : {
1008 | "stringUnit" : {
1009 | "state" : "translated",
1010 | "value" : "Unknown"
1011 | }
1012 | },
1013 | "sv" : {
1014 | "stringUnit" : {
1015 | "state" : "translated",
1016 | "value" : "Okänd"
1017 | }
1018 | }
1019 | }
1020 | },
1021 | "Vegan" : {
1022 | "extractionState" : "manual",
1023 | "localizations" : {
1024 | "en" : {
1025 | "stringUnit" : {
1026 | "state" : "translated",
1027 | "value" : "Vegan"
1028 | }
1029 | },
1030 | "sv" : {
1031 | "stringUnit" : {
1032 | "state" : "translated",
1033 | "value" : "Vegansk"
1034 | }
1035 | }
1036 | }
1037 | },
1038 | "Vegetarian" : {
1039 | "extractionState" : "manual",
1040 | "localizations" : {
1041 | "en" : {
1042 | "stringUnit" : {
1043 | "state" : "translated",
1044 | "value" : "Vegetarian"
1045 | }
1046 | },
1047 | "sv" : {
1048 | "stringUnit" : {
1049 | "state" : "translated",
1050 | "value" : "Vegetarian"
1051 | }
1052 | }
1053 | }
1054 | },
1055 | "Website" : {
1056 | "localizations" : {
1057 | "en" : {
1058 | "stringUnit" : {
1059 | "state" : "translated",
1060 | "value" : "Website"
1061 | }
1062 | },
1063 | "sv" : {
1064 | "stringUnit" : {
1065 | "state" : "translated",
1066 | "value" : "Hemsida"
1067 | }
1068 | }
1069 | }
1070 | },
1071 | "YouTube" : {
1072 | "localizations" : {
1073 | "en" : {
1074 | "stringUnit" : {
1075 | "state" : "translated",
1076 | "value" : "YouTube"
1077 | }
1078 | },
1079 | "sv" : {
1080 | "stringUnit" : {
1081 | "state" : "translated",
1082 | "value" : "YouTube"
1083 | }
1084 | }
1085 | }
1086 | }
1087 | },
1088 | "version" : "1.0"
1089 | }
--------------------------------------------------------------------------------
/Cuisine.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 63;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 6A395DCC2C192FCE00FE2D77 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 6A395DCB2C192FCE00FE2D77 /* Localizable.xcstrings */; };
11 | 6A395DD02C1942C200FE2D77 /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A395DCF2C1942BF00FE2D77 /* String+.swift */; };
12 | 6A395DD22C19489200FE2D77 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A395DD12C19488D00FE2D77 /* ImageLoader.swift */; };
13 | 6A395DD42C1948B100FE2D77 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A395DD32C1948AF00FE2D77 /* RemoteImage.swift */; };
14 | 6A3C946E2C1621220082BF8F /* URLSession+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3C946D2C1621220082BF8F /* URLSession+.swift */; };
15 | 6A3C94702C1621310082BF8F /* URLRequest+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3C946F2C1621310082BF8F /* URLRequest+.swift */; };
16 | 6A3D733F2C1A21730020F310 /* LoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3D733E2C1A21700020F310 /* LoadingState.swift */; };
17 | 6A49968D2C1418B300E2A92A /* RecipeDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A49968C2C1418B300E2A92A /* RecipeDetailsHeaderView.swift */; };
18 | 6A49968F2C141A9700E2A92A /* RecipeDetailsAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A49968E2C141A9700E2A92A /* RecipeDetailsAboutView.swift */; };
19 | 6A4996912C141BC500E2A92A /* RecipeDetailsIngredientsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4996902C141BC500E2A92A /* RecipeDetailsIngredientsView.swift */; };
20 | 6A4996932C141CD800E2A92A /* RecipeDetailsInstructionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4996922C141CD800E2A92A /* RecipeDetailsInstructionsView.swift */; };
21 | 6A4996952C141D8400E2A92A /* RecipeDetailsTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4996942C141D8400E2A92A /* RecipeDetailsTagsView.swift */; };
22 | 6A4996992C149EA100E2A92A /* SkeletonDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4996982C149EA100E2A92A /* SkeletonDetailsView.swift */; };
23 | 6A49969B2C14A4D200E2A92A /* EmptyDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A49969A2C14A4D200E2A92A /* EmptyDetailsView.swift */; };
24 | 6A49969D2C14AB0400E2A92A /* MealDetailAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A49969C2C14AB0400E2A92A /* MealDetailAbout.swift */; };
25 | 6A49969E2C14BFEB00E2A92A /* Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CB62C136C1F001764B9 /* Meal.swift */; };
26 | 6A4996A02C14CCE900E2A92A /* MealCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CBE2C138CA8001764B9 /* MealCategory.swift */; };
27 | 6A4996A12C14CCE900E2A92A /* MealCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CBE2C138CA8001764B9 /* MealCategory.swift */; };
28 | 6A4996A22C14CD6500E2A92A /* MealFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CC82C138E84001764B9 /* MealFilter.swift */; };
29 | 6A60E2182C13D28300194166 /* RoundedRectBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A60E2172C13D28300194166 /* RoundedRectBackground.swift */; };
30 | 6A60E21A2C13D2AB00194166 /* View+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A60E2192C13D2AB00194166 /* View+.swift */; };
31 | 6A60E21C2C13D68700194166 /* RecipeDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A60E21B2C13D68700194166 /* RecipeDetailsView.swift */; };
32 | 6A60E21E2C13E8D300194166 /* MealDetailsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A60E21D2C13E8D300194166 /* MealDetailsResponse.swift */; };
33 | 6A7D8CB72C136C1F001764B9 /* Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CB62C136C1F001764B9 /* Meal.swift */; };
34 | 6A7D8CB92C137B8D001764B9 /* FetchAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CB82C137B8D001764B9 /* FetchAPI.swift */; };
35 | 6A7D8CBC2C138C46001764B9 /* MealDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CBB2C138C46001764B9 /* MealDetail.swift */; };
36 | 6A7D8CBF2C138CA8001764B9 /* MealCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CBE2C138CA8001764B9 /* MealCategory.swift */; };
37 | 6A7D8CC22C138CF8001764B9 /* EdgeInsets+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CC12C138CF8001764B9 /* EdgeInsets+.swift */; };
38 | 6A7D8CC62C138DA3001764B9 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CC52C138DA3001764B9 /* MainViewModel.swift */; };
39 | 6A7D8CC92C138E84001764B9 /* MealFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CC82C138E84001764B9 /* MealFilter.swift */; };
40 | 6A7D8CCC2C138F81001764B9 /* MealService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CCB2C138F81001764B9 /* MealService.swift */; };
41 | 6A7D8CCE2C1391C5001764B9 /* MealsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CCD2C1391C5001764B9 /* MealsResponse.swift */; };
42 | 6A7D8CD02C1399DA001764B9 /* EmptyMealsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CCF2C1399DA001764B9 /* EmptyMealsView.swift */; };
43 | 6A7D8CD22C139B25001764B9 /* SkeletonMealsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CD12C139B25001764B9 /* SkeletonMealsView.swift */; };
44 | 6A7D8CD42C13A08B001764B9 /* MealCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CD32C13A08B001764B9 /* MealCategoryView.swift */; };
45 | 6A7D8CD82C13A1F2001764B9 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CD72C13A1F2001764B9 /* RecipeView.swift */; };
46 | 6A7D8CDA2C13AEA2001764B9 /* MealFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CD92C13AEA2001764B9 /* MealFilterButton.swift */; };
47 | 6A7D8CDD2C13B6E6001764B9 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7D8CDC2C13B6E6001764B9 /* ImageCache.swift */; };
48 | 6A86B9BB2C19F84400BD3CC3 /* CuisineLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A86B9BA2C19F83E00BD3CC3 /* CuisineLocalizationTests.swift */; };
49 | 6A86B9BD2C1A113900BD3CC3 /* RecipeDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A86B9BC2C1A113900BD3CC3 /* RecipeDetailsViewModel.swift */; };
50 | 6A8FFECF2C13CC0D00197258 /* MealError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8FFECE2C13CC0D00197258 /* MealError.swift */; };
51 | 6AD288CD2C18BBA4003A8F40 /* SectionTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD288CC2C18BBA4003A8F40 /* SectionTitle.swift */; };
52 | 6AD288D02C18CB7E003A8F40 /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD288CF2C18CB7B003A8F40 /* Network.swift */; };
53 | 6AD288D42C18D405003A8F40 /* CuisineNetworkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD288D32C18D3FC003A8F40 /* CuisineNetworkTests.swift */; };
54 | 6AD288D72C18D618003A8F40 /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD288D62C18D604003A8F40 /* MockURLProtocol.swift */; };
55 | 6AD288D92C18D7DB003A8F40 /* MockDataJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD288D82C18D7D6003A8F40 /* MockDataJSON.swift */; };
56 | 6AD430692C0F8080008BD892 /* CuisineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD430682C0F8080008BD892 /* CuisineApp.swift */; };
57 | 6AD4306B2C0F8080008BD892 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD4306A2C0F8080008BD892 /* MainView.swift */; };
58 | 6AD4306D2C0F8081008BD892 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6AD4306C2C0F8081008BD892 /* Assets.xcassets */; };
59 | 6AD430702C0F8081008BD892 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6AD4306F2C0F8081008BD892 /* Preview Assets.xcassets */; };
60 | 6AD4307A2C0F8081008BD892 /* CuisineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD430792C0F8081008BD892 /* CuisineTests.swift */; };
61 | 6AD430842C0F8081008BD892 /* CuisineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD430832C0F8081008BD892 /* CuisineUITests.swift */; };
62 | 6AD430862C0F8081008BD892 /* CuisineUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD430852C0F8081008BD892 /* CuisineUITestsLaunchTests.swift */; };
63 | /* End PBXBuildFile section */
64 |
65 | /* Begin PBXContainerItemProxy section */
66 | 6AD430762C0F8081008BD892 /* PBXContainerItemProxy */ = {
67 | isa = PBXContainerItemProxy;
68 | containerPortal = 6AD4305D2C0F8080008BD892 /* Project object */;
69 | proxyType = 1;
70 | remoteGlobalIDString = 6AD430642C0F8080008BD892;
71 | remoteInfo = Cuisine;
72 | };
73 | 6AD430802C0F8081008BD892 /* PBXContainerItemProxy */ = {
74 | isa = PBXContainerItemProxy;
75 | containerPortal = 6AD4305D2C0F8080008BD892 /* Project object */;
76 | proxyType = 1;
77 | remoteGlobalIDString = 6AD430642C0F8080008BD892;
78 | remoteInfo = Cuisine;
79 | };
80 | /* End PBXContainerItemProxy section */
81 |
82 | /* Begin PBXFileReference section */
83 | 6A395DCB2C192FCE00FE2D77 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
84 | 6A395DCF2C1942BF00FE2D77 /* String+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = ""; };
85 | 6A395DD12C19488D00FE2D77 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; };
86 | 6A395DD32C1948AF00FE2D77 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; };
87 | 6A3C946D2C1621220082BF8F /* URLSession+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLSession+.swift"; sourceTree = ""; };
88 | 6A3C946F2C1621310082BF8F /* URLRequest+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+.swift"; sourceTree = ""; };
89 | 6A3D733E2C1A21700020F310 /* LoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingState.swift; sourceTree = ""; };
90 | 6A49968C2C1418B300E2A92A /* RecipeDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsHeaderView.swift; sourceTree = ""; };
91 | 6A49968E2C141A9700E2A92A /* RecipeDetailsAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsAboutView.swift; sourceTree = ""; };
92 | 6A4996902C141BC500E2A92A /* RecipeDetailsIngredientsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsIngredientsView.swift; sourceTree = ""; };
93 | 6A4996922C141CD800E2A92A /* RecipeDetailsInstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsInstructionsView.swift; sourceTree = ""; };
94 | 6A4996942C141D8400E2A92A /* RecipeDetailsTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsTagsView.swift; sourceTree = ""; };
95 | 6A4996982C149EA100E2A92A /* SkeletonDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonDetailsView.swift; sourceTree = ""; };
96 | 6A49969A2C14A4D200E2A92A /* EmptyDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyDetailsView.swift; sourceTree = ""; };
97 | 6A49969C2C14AB0400E2A92A /* MealDetailAbout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetailAbout.swift; sourceTree = ""; };
98 | 6A60E2172C13D28300194166 /* RoundedRectBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedRectBackground.swift; sourceTree = ""; };
99 | 6A60E2192C13D2AB00194166 /* View+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+.swift"; sourceTree = ""; };
100 | 6A60E21B2C13D68700194166 /* RecipeDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsView.swift; sourceTree = ""; };
101 | 6A60E21D2C13E8D300194166 /* MealDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetailsResponse.swift; sourceTree = ""; };
102 | 6A7D8CB62C136C1F001764B9 /* Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meal.swift; sourceTree = ""; };
103 | 6A7D8CB82C137B8D001764B9 /* FetchAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchAPI.swift; sourceTree = ""; };
104 | 6A7D8CBB2C138C46001764B9 /* MealDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetail.swift; sourceTree = ""; };
105 | 6A7D8CBE2C138CA8001764B9 /* MealCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCategory.swift; sourceTree = ""; };
106 | 6A7D8CC12C138CF8001764B9 /* EdgeInsets+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EdgeInsets+.swift"; sourceTree = ""; };
107 | 6A7D8CC52C138DA3001764B9 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; };
108 | 6A7D8CC82C138E84001764B9 /* MealFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealFilter.swift; sourceTree = ""; };
109 | 6A7D8CCB2C138F81001764B9 /* MealService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealService.swift; sourceTree = ""; };
110 | 6A7D8CCD2C1391C5001764B9 /* MealsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealsResponse.swift; sourceTree = ""; };
111 | 6A7D8CCF2C1399DA001764B9 /* EmptyMealsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyMealsView.swift; sourceTree = ""; };
112 | 6A7D8CD12C139B25001764B9 /* SkeletonMealsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonMealsView.swift; sourceTree = ""; };
113 | 6A7D8CD32C13A08B001764B9 /* MealCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCategoryView.swift; sourceTree = ""; };
114 | 6A7D8CD72C13A1F2001764B9 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = ""; };
115 | 6A7D8CD92C13AEA2001764B9 /* MealFilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealFilterButton.swift; sourceTree = ""; };
116 | 6A7D8CDC2C13B6E6001764B9 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; };
117 | 6A86B9BA2C19F83E00BD3CC3 /* CuisineLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineLocalizationTests.swift; sourceTree = ""; };
118 | 6A86B9BC2C1A113900BD3CC3 /* RecipeDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailsViewModel.swift; sourceTree = ""; };
119 | 6A8FFECE2C13CC0D00197258 /* MealError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealError.swift; sourceTree = ""; };
120 | 6AD288CC2C18BBA4003A8F40 /* SectionTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionTitle.swift; sourceTree = ""; };
121 | 6AD288CF2C18CB7B003A8F40 /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; };
122 | 6AD288D32C18D3FC003A8F40 /* CuisineNetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineNetworkTests.swift; sourceTree = ""; };
123 | 6AD288D62C18D604003A8F40 /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; };
124 | 6AD288D82C18D7D6003A8F40 /* MockDataJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataJSON.swift; sourceTree = ""; };
125 | 6AD430652C0F8080008BD892 /* Cuisine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cuisine.app; sourceTree = BUILT_PRODUCTS_DIR; };
126 | 6AD430682C0F8080008BD892 /* CuisineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineApp.swift; sourceTree = ""; };
127 | 6AD4306A2C0F8080008BD892 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; };
128 | 6AD4306C2C0F8081008BD892 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
129 | 6AD4306F2C0F8081008BD892 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
130 | 6AD430752C0F8081008BD892 /* CuisineTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CuisineTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
131 | 6AD430792C0F8081008BD892 /* CuisineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineTests.swift; sourceTree = ""; };
132 | 6AD4307F2C0F8081008BD892 /* CuisineUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CuisineUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
133 | 6AD430832C0F8081008BD892 /* CuisineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineUITests.swift; sourceTree = ""; };
134 | 6AD430852C0F8081008BD892 /* CuisineUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuisineUITestsLaunchTests.swift; sourceTree = ""; };
135 | /* End PBXFileReference section */
136 |
137 | /* Begin PBXFrameworksBuildPhase section */
138 | 6AD430622C0F8080008BD892 /* Frameworks */ = {
139 | isa = PBXFrameworksBuildPhase;
140 | buildActionMask = 2147483647;
141 | files = (
142 | );
143 | runOnlyForDeploymentPostprocessing = 0;
144 | };
145 | 6AD430722C0F8081008BD892 /* Frameworks */ = {
146 | isa = PBXFrameworksBuildPhase;
147 | buildActionMask = 2147483647;
148 | files = (
149 | );
150 | runOnlyForDeploymentPostprocessing = 0;
151 | };
152 | 6AD4307C2C0F8081008BD892 /* Frameworks */ = {
153 | isa = PBXFrameworksBuildPhase;
154 | buildActionMask = 2147483647;
155 | files = (
156 | );
157 | runOnlyForDeploymentPostprocessing = 0;
158 | };
159 | /* End PBXFrameworksBuildPhase section */
160 |
161 | /* Begin PBXGroup section */
162 | 6A49968B2C14182500E2A92A /* Recipe Details */ = {
163 | isa = PBXGroup;
164 | children = (
165 | 6A86B9BE2C1A127300BD3CC3 /* View Model */,
166 | 6A60E21B2C13D68700194166 /* RecipeDetailsView.swift */,
167 | 6A49968C2C1418B300E2A92A /* RecipeDetailsHeaderView.swift */,
168 | 6A49968E2C141A9700E2A92A /* RecipeDetailsAboutView.swift */,
169 | 6A4996902C141BC500E2A92A /* RecipeDetailsIngredientsView.swift */,
170 | 6A4996922C141CD800E2A92A /* RecipeDetailsInstructionsView.swift */,
171 | 6A4996942C141D8400E2A92A /* RecipeDetailsTagsView.swift */,
172 | 6A4996962C149E4100E2A92A /* Skeleton View */,
173 | 6A4996972C149E4C00E2A92A /* Empty View */,
174 | );
175 | path = "Recipe Details";
176 | sourceTree = "";
177 | };
178 | 6A4996962C149E4100E2A92A /* Skeleton View */ = {
179 | isa = PBXGroup;
180 | children = (
181 | 6A4996982C149EA100E2A92A /* SkeletonDetailsView.swift */,
182 | );
183 | path = "Skeleton View";
184 | sourceTree = "";
185 | };
186 | 6A4996972C149E4C00E2A92A /* Empty View */ = {
187 | isa = PBXGroup;
188 | children = (
189 | 6A49969A2C14A4D200E2A92A /* EmptyDetailsView.swift */,
190 | );
191 | path = "Empty View";
192 | sourceTree = "";
193 | };
194 | 6A60E2162C13D21800194166 /* View Modifiers */ = {
195 | isa = PBXGroup;
196 | children = (
197 | 6A60E2172C13D28300194166 /* RoundedRectBackground.swift */,
198 | 6AD288CC2C18BBA4003A8F40 /* SectionTitle.swift */,
199 | );
200 | path = "View Modifiers";
201 | sourceTree = "";
202 | };
203 | 6A7D8CBA2C138C2F001764B9 /* Models */ = {
204 | isa = PBXGroup;
205 | children = (
206 | 6A3D733E2C1A21700020F310 /* LoadingState.swift */,
207 | 6A7D8CB62C136C1F001764B9 /* Meal.swift */,
208 | 6A7D8CBE2C138CA8001764B9 /* MealCategory.swift */,
209 | 6A7D8CBB2C138C46001764B9 /* MealDetail.swift */,
210 | 6A7D8CC82C138E84001764B9 /* MealFilter.swift */,
211 | 6A7D8CCD2C1391C5001764B9 /* MealsResponse.swift */,
212 | 6A60E21D2C13E8D300194166 /* MealDetailsResponse.swift */,
213 | 6A49969C2C14AB0400E2A92A /* MealDetailAbout.swift */,
214 | );
215 | path = Models;
216 | sourceTree = "";
217 | };
218 | 6A7D8CBD2C138C6A001764B9 /* Endpoints */ = {
219 | isa = PBXGroup;
220 | children = (
221 | 6A7D8CB82C137B8D001764B9 /* FetchAPI.swift */,
222 | );
223 | path = Endpoints;
224 | sourceTree = "";
225 | };
226 | 6A7D8CC02C138CEC001764B9 /* Extensions */ = {
227 | isa = PBXGroup;
228 | children = (
229 | 6A395DCF2C1942BF00FE2D77 /* String+.swift */,
230 | 6A7D8CC12C138CF8001764B9 /* EdgeInsets+.swift */,
231 | 6A3C946F2C1621310082BF8F /* URLRequest+.swift */,
232 | 6A3C946D2C1621220082BF8F /* URLSession+.swift */,
233 | 6A60E2192C13D2AB00194166 /* View+.swift */,
234 | );
235 | path = Extensions;
236 | sourceTree = "";
237 | };
238 | 6A7D8CC42C138D91001764B9 /* Main */ = {
239 | isa = PBXGroup;
240 | children = (
241 | 6A7D8CC72C138DC9001764B9 /* View Model */,
242 | 6A8FFED02C13D12C00197258 /* Views */,
243 | );
244 | path = Main;
245 | sourceTree = "";
246 | };
247 | 6A7D8CC72C138DC9001764B9 /* View Model */ = {
248 | isa = PBXGroup;
249 | children = (
250 | 6A7D8CC52C138DA3001764B9 /* MainViewModel.swift */,
251 | );
252 | path = "View Model";
253 | sourceTree = "";
254 | };
255 | 6A7D8CCA2C138F5A001764B9 /* Services */ = {
256 | isa = PBXGroup;
257 | children = (
258 | 6A7D8CCB2C138F81001764B9 /* MealService.swift */,
259 | 6A7D8CBD2C138C6A001764B9 /* Endpoints */,
260 | 6AD288CE2C18CB68003A8F40 /* Network */,
261 | );
262 | path = Services;
263 | sourceTree = "";
264 | };
265 | 6A7D8CDB2C13B691001764B9 /* Cache */ = {
266 | isa = PBXGroup;
267 | children = (
268 | 6A7D8CDC2C13B6E6001764B9 /* ImageCache.swift */,
269 | 6A395DD12C19488D00FE2D77 /* ImageLoader.swift */,
270 | 6A395DD32C1948AF00FE2D77 /* RemoteImage.swift */,
271 | );
272 | path = Cache;
273 | sourceTree = "";
274 | };
275 | 6A86B9B92C19F82C00BD3CC3 /* Localization Tests */ = {
276 | isa = PBXGroup;
277 | children = (
278 | 6A86B9BA2C19F83E00BD3CC3 /* CuisineLocalizationTests.swift */,
279 | );
280 | path = "Localization Tests";
281 | sourceTree = "";
282 | };
283 | 6A86B9BE2C1A127300BD3CC3 /* View Model */ = {
284 | isa = PBXGroup;
285 | children = (
286 | 6A86B9BC2C1A113900BD3CC3 /* RecipeDetailsViewModel.swift */,
287 | );
288 | path = "View Model";
289 | sourceTree = "";
290 | };
291 | 6A8FFECD2C13CBCB00197258 /* Error Handling */ = {
292 | isa = PBXGroup;
293 | children = (
294 | 6A8FFECE2C13CC0D00197258 /* MealError.swift */,
295 | );
296 | path = "Error Handling";
297 | sourceTree = "";
298 | };
299 | 6A8FFED02C13D12C00197258 /* Views */ = {
300 | isa = PBXGroup;
301 | children = (
302 | 6AD4306A2C0F8080008BD892 /* MainView.swift */,
303 | 6A7D8CD32C13A08B001764B9 /* MealCategoryView.swift */,
304 | 6A7D8CD72C13A1F2001764B9 /* RecipeView.swift */,
305 | 6A49968B2C14182500E2A92A /* Recipe Details */,
306 | 6A8FFED12C13D14000197258 /* Skeleton View */,
307 | 6A8FFED22C13D14A00197258 /* Empty View */,
308 | 6A8FFED42C13D17200197258 /* Toolbar */,
309 | );
310 | path = Views;
311 | sourceTree = "";
312 | };
313 | 6A8FFED12C13D14000197258 /* Skeleton View */ = {
314 | isa = PBXGroup;
315 | children = (
316 | 6A7D8CD12C139B25001764B9 /* SkeletonMealsView.swift */,
317 | );
318 | path = "Skeleton View";
319 | sourceTree = "";
320 | };
321 | 6A8FFED22C13D14A00197258 /* Empty View */ = {
322 | isa = PBXGroup;
323 | children = (
324 | 6A7D8CCF2C1399DA001764B9 /* EmptyMealsView.swift */,
325 | );
326 | path = "Empty View";
327 | sourceTree = "";
328 | };
329 | 6A8FFED42C13D17200197258 /* Toolbar */ = {
330 | isa = PBXGroup;
331 | children = (
332 | 6A7D8CD92C13AEA2001764B9 /* MealFilterButton.swift */,
333 | );
334 | path = Toolbar;
335 | sourceTree = "";
336 | };
337 | 6AD288CE2C18CB68003A8F40 /* Network */ = {
338 | isa = PBXGroup;
339 | children = (
340 | 6AD288CF2C18CB7B003A8F40 /* Network.swift */,
341 | );
342 | path = Network;
343 | sourceTree = "";
344 | };
345 | 6AD288D52C18D5FC003A8F40 /* Network Tests */ = {
346 | isa = PBXGroup;
347 | children = (
348 | 6AD288D32C18D3FC003A8F40 /* CuisineNetworkTests.swift */,
349 | 6AD288D82C18D7D6003A8F40 /* MockDataJSON.swift */,
350 | 6AD288D62C18D604003A8F40 /* MockURLProtocol.swift */,
351 | );
352 | path = "Network Tests";
353 | sourceTree = "";
354 | };
355 | 6AD288DF2C18EEF3003A8F40 /* Main Tests */ = {
356 | isa = PBXGroup;
357 | children = (
358 | 6AD430792C0F8081008BD892 /* CuisineTests.swift */,
359 | );
360 | path = "Main Tests";
361 | sourceTree = "";
362 | };
363 | 6AD288E02C192DBA003A8F40 /* Localization */ = {
364 | isa = PBXGroup;
365 | children = (
366 | 6A395DCB2C192FCE00FE2D77 /* Localizable.xcstrings */,
367 | );
368 | path = Localization;
369 | sourceTree = "";
370 | };
371 | 6AD4305C2C0F8080008BD892 = {
372 | isa = PBXGroup;
373 | children = (
374 | 6AD430672C0F8080008BD892 /* Cuisine */,
375 | 6AD430782C0F8081008BD892 /* CuisineTests */,
376 | 6AD430822C0F8081008BD892 /* CuisineUITests */,
377 | 6AD430662C0F8080008BD892 /* Products */,
378 | );
379 | sourceTree = "";
380 | };
381 | 6AD430662C0F8080008BD892 /* Products */ = {
382 | isa = PBXGroup;
383 | children = (
384 | 6AD430652C0F8080008BD892 /* Cuisine.app */,
385 | 6AD430752C0F8081008BD892 /* CuisineTests.xctest */,
386 | 6AD4307F2C0F8081008BD892 /* CuisineUITests.xctest */,
387 | );
388 | name = Products;
389 | sourceTree = "";
390 | };
391 | 6AD430672C0F8080008BD892 /* Cuisine */ = {
392 | isa = PBXGroup;
393 | children = (
394 | 6AD430682C0F8080008BD892 /* CuisineApp.swift */,
395 | 6A7D8CC42C138D91001764B9 /* Main */,
396 | 6A7D8CCA2C138F5A001764B9 /* Services */,
397 | 6A7D8CBA2C138C2F001764B9 /* Models */,
398 | 6A7D8CDB2C13B691001764B9 /* Cache */,
399 | 6A8FFECD2C13CBCB00197258 /* Error Handling */,
400 | 6A60E2162C13D21800194166 /* View Modifiers */,
401 | 6AD288E02C192DBA003A8F40 /* Localization */,
402 | 6A7D8CC02C138CEC001764B9 /* Extensions */,
403 | 6AD4306C2C0F8081008BD892 /* Assets.xcassets */,
404 | 6AD4306E2C0F8081008BD892 /* Preview Content */,
405 | );
406 | path = Cuisine;
407 | sourceTree = "";
408 | };
409 | 6AD4306E2C0F8081008BD892 /* Preview Content */ = {
410 | isa = PBXGroup;
411 | children = (
412 | 6AD4306F2C0F8081008BD892 /* Preview Assets.xcassets */,
413 | );
414 | path = "Preview Content";
415 | sourceTree = "";
416 | };
417 | 6AD430782C0F8081008BD892 /* CuisineTests */ = {
418 | isa = PBXGroup;
419 | children = (
420 | 6A86B9B92C19F82C00BD3CC3 /* Localization Tests */,
421 | 6AD288DF2C18EEF3003A8F40 /* Main Tests */,
422 | 6AD288D52C18D5FC003A8F40 /* Network Tests */,
423 | );
424 | path = CuisineTests;
425 | sourceTree = "";
426 | };
427 | 6AD430822C0F8081008BD892 /* CuisineUITests */ = {
428 | isa = PBXGroup;
429 | children = (
430 | 6AD430832C0F8081008BD892 /* CuisineUITests.swift */,
431 | 6AD430852C0F8081008BD892 /* CuisineUITestsLaunchTests.swift */,
432 | );
433 | path = CuisineUITests;
434 | sourceTree = "";
435 | };
436 | /* End PBXGroup section */
437 |
438 | /* Begin PBXNativeTarget section */
439 | 6AD430642C0F8080008BD892 /* Cuisine */ = {
440 | isa = PBXNativeTarget;
441 | buildConfigurationList = 6AD430892C0F8081008BD892 /* Build configuration list for PBXNativeTarget "Cuisine" */;
442 | buildPhases = (
443 | 6AD430612C0F8080008BD892 /* Sources */,
444 | 6AD430622C0F8080008BD892 /* Frameworks */,
445 | 6AD430632C0F8080008BD892 /* Resources */,
446 | );
447 | buildRules = (
448 | );
449 | dependencies = (
450 | );
451 | name = Cuisine;
452 | productName = Cuisine;
453 | productReference = 6AD430652C0F8080008BD892 /* Cuisine.app */;
454 | productType = "com.apple.product-type.application";
455 | };
456 | 6AD430742C0F8081008BD892 /* CuisineTests */ = {
457 | isa = PBXNativeTarget;
458 | buildConfigurationList = 6AD4308C2C0F8081008BD892 /* Build configuration list for PBXNativeTarget "CuisineTests" */;
459 | buildPhases = (
460 | 6AD430712C0F8081008BD892 /* Sources */,
461 | 6AD430722C0F8081008BD892 /* Frameworks */,
462 | 6AD430732C0F8081008BD892 /* Resources */,
463 | );
464 | buildRules = (
465 | );
466 | dependencies = (
467 | 6AD430772C0F8081008BD892 /* PBXTargetDependency */,
468 | );
469 | name = CuisineTests;
470 | productName = CuisineTests;
471 | productReference = 6AD430752C0F8081008BD892 /* CuisineTests.xctest */;
472 | productType = "com.apple.product-type.bundle.unit-test";
473 | };
474 | 6AD4307E2C0F8081008BD892 /* CuisineUITests */ = {
475 | isa = PBXNativeTarget;
476 | buildConfigurationList = 6AD4308F2C0F8081008BD892 /* Build configuration list for PBXNativeTarget "CuisineUITests" */;
477 | buildPhases = (
478 | 6AD4307B2C0F8081008BD892 /* Sources */,
479 | 6AD4307C2C0F8081008BD892 /* Frameworks */,
480 | 6AD4307D2C0F8081008BD892 /* Resources */,
481 | );
482 | buildRules = (
483 | );
484 | dependencies = (
485 | 6AD430812C0F8081008BD892 /* PBXTargetDependency */,
486 | );
487 | name = CuisineUITests;
488 | productName = CuisineUITests;
489 | productReference = 6AD4307F2C0F8081008BD892 /* CuisineUITests.xctest */;
490 | productType = "com.apple.product-type.bundle.ui-testing";
491 | };
492 | /* End PBXNativeTarget section */
493 |
494 | /* Begin PBXProject section */
495 | 6AD4305D2C0F8080008BD892 /* Project object */ = {
496 | isa = PBXProject;
497 | attributes = {
498 | BuildIndependentTargetsInParallel = 1;
499 | LastSwiftUpdateCheck = 1540;
500 | LastUpgradeCheck = 1540;
501 | TargetAttributes = {
502 | 6AD430642C0F8080008BD892 = {
503 | CreatedOnToolsVersion = 15.4;
504 | };
505 | 6AD430742C0F8081008BD892 = {
506 | CreatedOnToolsVersion = 15.4;
507 | TestTargetID = 6AD430642C0F8080008BD892;
508 | };
509 | 6AD4307E2C0F8081008BD892 = {
510 | CreatedOnToolsVersion = 15.4;
511 | TestTargetID = 6AD430642C0F8080008BD892;
512 | };
513 | };
514 | };
515 | buildConfigurationList = 6AD430602C0F8080008BD892 /* Build configuration list for PBXProject "Cuisine" */;
516 | compatibilityVersion = "Xcode 15.3";
517 | developmentRegion = en;
518 | hasScannedForEncodings = 0;
519 | knownRegions = (
520 | en,
521 | Base,
522 | sv,
523 | );
524 | mainGroup = 6AD4305C2C0F8080008BD892;
525 | productRefGroup = 6AD430662C0F8080008BD892 /* Products */;
526 | projectDirPath = "";
527 | projectRoot = "";
528 | targets = (
529 | 6AD430642C0F8080008BD892 /* Cuisine */,
530 | 6AD430742C0F8081008BD892 /* CuisineTests */,
531 | 6AD4307E2C0F8081008BD892 /* CuisineUITests */,
532 | );
533 | };
534 | /* End PBXProject section */
535 |
536 | /* Begin PBXResourcesBuildPhase section */
537 | 6AD430632C0F8080008BD892 /* Resources */ = {
538 | isa = PBXResourcesBuildPhase;
539 | buildActionMask = 2147483647;
540 | files = (
541 | 6AD430702C0F8081008BD892 /* Preview Assets.xcassets in Resources */,
542 | 6AD4306D2C0F8081008BD892 /* Assets.xcassets in Resources */,
543 | 6A395DCC2C192FCE00FE2D77 /* Localizable.xcstrings in Resources */,
544 | );
545 | runOnlyForDeploymentPostprocessing = 0;
546 | };
547 | 6AD430732C0F8081008BD892 /* Resources */ = {
548 | isa = PBXResourcesBuildPhase;
549 | buildActionMask = 2147483647;
550 | files = (
551 | );
552 | runOnlyForDeploymentPostprocessing = 0;
553 | };
554 | 6AD4307D2C0F8081008BD892 /* Resources */ = {
555 | isa = PBXResourcesBuildPhase;
556 | buildActionMask = 2147483647;
557 | files = (
558 | );
559 | runOnlyForDeploymentPostprocessing = 0;
560 | };
561 | /* End PBXResourcesBuildPhase section */
562 |
563 | /* Begin PBXSourcesBuildPhase section */
564 | 6AD430612C0F8080008BD892 /* Sources */ = {
565 | isa = PBXSourcesBuildPhase;
566 | buildActionMask = 2147483647;
567 | files = (
568 | 6A7D8CC92C138E84001764B9 /* MealFilter.swift in Sources */,
569 | 6A86B9BD2C1A113900BD3CC3 /* RecipeDetailsViewModel.swift in Sources */,
570 | 6A7D8CC22C138CF8001764B9 /* EdgeInsets+.swift in Sources */,
571 | 6AD288CD2C18BBA4003A8F40 /* SectionTitle.swift in Sources */,
572 | 6A49969B2C14A4D200E2A92A /* EmptyDetailsView.swift in Sources */,
573 | 6A49969D2C14AB0400E2A92A /* MealDetailAbout.swift in Sources */,
574 | 6A7D8CDD2C13B6E6001764B9 /* ImageCache.swift in Sources */,
575 | 6A7D8CDA2C13AEA2001764B9 /* MealFilterButton.swift in Sources */,
576 | 6A7D8CBF2C138CA8001764B9 /* MealCategory.swift in Sources */,
577 | 6A4996912C141BC500E2A92A /* RecipeDetailsIngredientsView.swift in Sources */,
578 | 6A4996932C141CD800E2A92A /* RecipeDetailsInstructionsView.swift in Sources */,
579 | 6AD288D02C18CB7E003A8F40 /* Network.swift in Sources */,
580 | 6A8FFECF2C13CC0D00197258 /* MealError.swift in Sources */,
581 | 6A395DD02C1942C200FE2D77 /* String+.swift in Sources */,
582 | 6A7D8CD22C139B25001764B9 /* SkeletonMealsView.swift in Sources */,
583 | 6A395DD22C19489200FE2D77 /* ImageLoader.swift in Sources */,
584 | 6A60E21C2C13D68700194166 /* RecipeDetailsView.swift in Sources */,
585 | 6A49968D2C1418B300E2A92A /* RecipeDetailsHeaderView.swift in Sources */,
586 | 6A3D733F2C1A21730020F310 /* LoadingState.swift in Sources */,
587 | 6AD4306B2C0F8080008BD892 /* MainView.swift in Sources */,
588 | 6A60E21A2C13D2AB00194166 /* View+.swift in Sources */,
589 | 6A7D8CCC2C138F81001764B9 /* MealService.swift in Sources */,
590 | 6A7D8CB92C137B8D001764B9 /* FetchAPI.swift in Sources */,
591 | 6A7D8CBC2C138C46001764B9 /* MealDetail.swift in Sources */,
592 | 6A7D8CD02C1399DA001764B9 /* EmptyMealsView.swift in Sources */,
593 | 6A3C94702C1621310082BF8F /* URLRequest+.swift in Sources */,
594 | 6A3C946E2C1621220082BF8F /* URLSession+.swift in Sources */,
595 | 6A7D8CD82C13A1F2001764B9 /* RecipeView.swift in Sources */,
596 | 6A395DD42C1948B100FE2D77 /* RemoteImage.swift in Sources */,
597 | 6A7D8CD42C13A08B001764B9 /* MealCategoryView.swift in Sources */,
598 | 6A60E21E2C13E8D300194166 /* MealDetailsResponse.swift in Sources */,
599 | 6A7D8CCE2C1391C5001764B9 /* MealsResponse.swift in Sources */,
600 | 6A49968F2C141A9700E2A92A /* RecipeDetailsAboutView.swift in Sources */,
601 | 6A7D8CB72C136C1F001764B9 /* Meal.swift in Sources */,
602 | 6A60E2182C13D28300194166 /* RoundedRectBackground.swift in Sources */,
603 | 6A4996952C141D8400E2A92A /* RecipeDetailsTagsView.swift in Sources */,
604 | 6AD430692C0F8080008BD892 /* CuisineApp.swift in Sources */,
605 | 6A4996992C149EA100E2A92A /* SkeletonDetailsView.swift in Sources */,
606 | 6A7D8CC62C138DA3001764B9 /* MainViewModel.swift in Sources */,
607 | );
608 | runOnlyForDeploymentPostprocessing = 0;
609 | };
610 | 6AD430712C0F8081008BD892 /* Sources */ = {
611 | isa = PBXSourcesBuildPhase;
612 | buildActionMask = 2147483647;
613 | files = (
614 | 6A4996A02C14CCE900E2A92A /* MealCategory.swift in Sources */,
615 | 6A86B9BB2C19F84400BD3CC3 /* CuisineLocalizationTests.swift in Sources */,
616 | 6AD4307A2C0F8081008BD892 /* CuisineTests.swift in Sources */,
617 | 6AD288D42C18D405003A8F40 /* CuisineNetworkTests.swift in Sources */,
618 | 6AD288D72C18D618003A8F40 /* MockURLProtocol.swift in Sources */,
619 | 6AD288D92C18D7DB003A8F40 /* MockDataJSON.swift in Sources */,
620 | );
621 | runOnlyForDeploymentPostprocessing = 0;
622 | };
623 | 6AD4307B2C0F8081008BD892 /* Sources */ = {
624 | isa = PBXSourcesBuildPhase;
625 | buildActionMask = 2147483647;
626 | files = (
627 | 6AD430842C0F8081008BD892 /* CuisineUITests.swift in Sources */,
628 | 6A49969E2C14BFEB00E2A92A /* Meal.swift in Sources */,
629 | 6A4996A12C14CCE900E2A92A /* MealCategory.swift in Sources */,
630 | 6A4996A22C14CD6500E2A92A /* MealFilter.swift in Sources */,
631 | 6AD430862C0F8081008BD892 /* CuisineUITestsLaunchTests.swift in Sources */,
632 | );
633 | runOnlyForDeploymentPostprocessing = 0;
634 | };
635 | /* End PBXSourcesBuildPhase section */
636 |
637 | /* Begin PBXTargetDependency section */
638 | 6AD430772C0F8081008BD892 /* PBXTargetDependency */ = {
639 | isa = PBXTargetDependency;
640 | target = 6AD430642C0F8080008BD892 /* Cuisine */;
641 | targetProxy = 6AD430762C0F8081008BD892 /* PBXContainerItemProxy */;
642 | };
643 | 6AD430812C0F8081008BD892 /* PBXTargetDependency */ = {
644 | isa = PBXTargetDependency;
645 | target = 6AD430642C0F8080008BD892 /* Cuisine */;
646 | targetProxy = 6AD430802C0F8081008BD892 /* PBXContainerItemProxy */;
647 | };
648 | /* End PBXTargetDependency section */
649 |
650 | /* Begin XCBuildConfiguration section */
651 | 6AD430872C0F8081008BD892 /* Debug */ = {
652 | isa = XCBuildConfiguration;
653 | buildSettings = {
654 | ALWAYS_SEARCH_USER_PATHS = NO;
655 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
656 | CLANG_ANALYZER_NONNULL = YES;
657 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
658 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
659 | CLANG_ENABLE_MODULES = YES;
660 | CLANG_ENABLE_OBJC_ARC = YES;
661 | CLANG_ENABLE_OBJC_WEAK = YES;
662 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
663 | CLANG_WARN_BOOL_CONVERSION = YES;
664 | CLANG_WARN_COMMA = YES;
665 | CLANG_WARN_CONSTANT_CONVERSION = YES;
666 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
667 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
668 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
669 | CLANG_WARN_EMPTY_BODY = YES;
670 | CLANG_WARN_ENUM_CONVERSION = YES;
671 | CLANG_WARN_INFINITE_RECURSION = YES;
672 | CLANG_WARN_INT_CONVERSION = YES;
673 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
674 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
675 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
676 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
677 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
678 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
679 | CLANG_WARN_STRICT_PROTOTYPES = YES;
680 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
681 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
682 | CLANG_WARN_UNREACHABLE_CODE = YES;
683 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
684 | COPY_PHASE_STRIP = NO;
685 | DEBUG_INFORMATION_FORMAT = dwarf;
686 | ENABLE_STRICT_OBJC_MSGSEND = YES;
687 | ENABLE_TESTABILITY = YES;
688 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
689 | GCC_C_LANGUAGE_STANDARD = gnu17;
690 | GCC_DYNAMIC_NO_PIC = NO;
691 | GCC_NO_COMMON_BLOCKS = YES;
692 | GCC_OPTIMIZATION_LEVEL = 0;
693 | GCC_PREPROCESSOR_DEFINITIONS = (
694 | "DEBUG=1",
695 | "$(inherited)",
696 | );
697 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
698 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
699 | GCC_WARN_UNDECLARED_SELECTOR = YES;
700 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
701 | GCC_WARN_UNUSED_FUNCTION = YES;
702 | GCC_WARN_UNUSED_VARIABLE = YES;
703 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
704 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
705 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
706 | MTL_FAST_MATH = YES;
707 | ONLY_ACTIVE_ARCH = YES;
708 | SDKROOT = iphoneos;
709 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
710 | SWIFT_EMIT_LOC_STRINGS = YES;
711 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
712 | };
713 | name = Debug;
714 | };
715 | 6AD430882C0F8081008BD892 /* Release */ = {
716 | isa = XCBuildConfiguration;
717 | buildSettings = {
718 | ALWAYS_SEARCH_USER_PATHS = NO;
719 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
720 | CLANG_ANALYZER_NONNULL = YES;
721 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
722 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
723 | CLANG_ENABLE_MODULES = YES;
724 | CLANG_ENABLE_OBJC_ARC = YES;
725 | CLANG_ENABLE_OBJC_WEAK = YES;
726 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
727 | CLANG_WARN_BOOL_CONVERSION = YES;
728 | CLANG_WARN_COMMA = YES;
729 | CLANG_WARN_CONSTANT_CONVERSION = YES;
730 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
731 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
732 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
733 | CLANG_WARN_EMPTY_BODY = YES;
734 | CLANG_WARN_ENUM_CONVERSION = YES;
735 | CLANG_WARN_INFINITE_RECURSION = YES;
736 | CLANG_WARN_INT_CONVERSION = YES;
737 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
738 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
739 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
740 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
741 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
742 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
743 | CLANG_WARN_STRICT_PROTOTYPES = YES;
744 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
745 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
746 | CLANG_WARN_UNREACHABLE_CODE = YES;
747 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
748 | COPY_PHASE_STRIP = NO;
749 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
750 | ENABLE_NS_ASSERTIONS = NO;
751 | ENABLE_STRICT_OBJC_MSGSEND = YES;
752 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
753 | GCC_C_LANGUAGE_STANDARD = gnu17;
754 | GCC_NO_COMMON_BLOCKS = YES;
755 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
756 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
757 | GCC_WARN_UNDECLARED_SELECTOR = YES;
758 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
759 | GCC_WARN_UNUSED_FUNCTION = YES;
760 | GCC_WARN_UNUSED_VARIABLE = YES;
761 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
762 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
763 | MTL_ENABLE_DEBUG_INFO = NO;
764 | MTL_FAST_MATH = YES;
765 | SDKROOT = iphoneos;
766 | SWIFT_COMPILATION_MODE = wholemodule;
767 | SWIFT_EMIT_LOC_STRINGS = YES;
768 | VALIDATE_PRODUCT = YES;
769 | };
770 | name = Release;
771 | };
772 | 6AD4308A2C0F8081008BD892 /* Debug */ = {
773 | isa = XCBuildConfiguration;
774 | buildSettings = {
775 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
776 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
777 | CODE_SIGN_STYLE = Automatic;
778 | CURRENT_PROJECT_VERSION = 1;
779 | DEVELOPMENT_ASSET_PATHS = "\"Cuisine/Preview Content\"";
780 | DEVELOPMENT_TEAM = J9S8P7XX49;
781 | ENABLE_PREVIEWS = YES;
782 | GENERATE_INFOPLIST_FILE = YES;
783 | INFOPLIST_KEY_CFBundleDisplayName = Cuisine;
784 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
785 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
786 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
787 | INFOPLIST_KEY_UIRequiresFullScreen = YES;
788 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
789 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
790 | LD_RUNPATH_SEARCH_PATHS = (
791 | "$(inherited)",
792 | "@executable_path/Frameworks",
793 | );
794 | MARKETING_VERSION = 1.0;
795 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.Cuisine;
796 | PRODUCT_NAME = "$(TARGET_NAME)";
797 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
798 | SUPPORTS_MACCATALYST = NO;
799 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
800 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
801 | SWIFT_EMIT_LOC_STRINGS = YES;
802 | SWIFT_VERSION = 5.0;
803 | TARGETED_DEVICE_FAMILY = "1,2";
804 | };
805 | name = Debug;
806 | };
807 | 6AD4308B2C0F8081008BD892 /* Release */ = {
808 | isa = XCBuildConfiguration;
809 | buildSettings = {
810 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
811 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
812 | CODE_SIGN_STYLE = Automatic;
813 | CURRENT_PROJECT_VERSION = 1;
814 | DEVELOPMENT_ASSET_PATHS = "\"Cuisine/Preview Content\"";
815 | DEVELOPMENT_TEAM = J9S8P7XX49;
816 | ENABLE_PREVIEWS = YES;
817 | GENERATE_INFOPLIST_FILE = YES;
818 | INFOPLIST_KEY_CFBundleDisplayName = Cuisine;
819 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
820 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
821 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
822 | INFOPLIST_KEY_UIRequiresFullScreen = YES;
823 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
824 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
825 | LD_RUNPATH_SEARCH_PATHS = (
826 | "$(inherited)",
827 | "@executable_path/Frameworks",
828 | );
829 | MARKETING_VERSION = 1.0;
830 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.Cuisine;
831 | PRODUCT_NAME = "$(TARGET_NAME)";
832 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
833 | SUPPORTS_MACCATALYST = NO;
834 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
835 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
836 | SWIFT_EMIT_LOC_STRINGS = YES;
837 | SWIFT_VERSION = 5.0;
838 | TARGETED_DEVICE_FAMILY = "1,2";
839 | };
840 | name = Release;
841 | };
842 | 6AD4308D2C0F8081008BD892 /* Debug */ = {
843 | isa = XCBuildConfiguration;
844 | buildSettings = {
845 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
846 | BUNDLE_LOADER = "$(TEST_HOST)";
847 | CODE_SIGN_STYLE = Automatic;
848 | CURRENT_PROJECT_VERSION = 1;
849 | DEVELOPMENT_TEAM = J9S8P7XX49;
850 | GENERATE_INFOPLIST_FILE = YES;
851 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
852 | MARKETING_VERSION = 1.0;
853 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.CuisineTests;
854 | PRODUCT_NAME = "$(TARGET_NAME)";
855 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
856 | SUPPORTS_MACCATALYST = NO;
857 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
858 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
859 | SWIFT_EMIT_LOC_STRINGS = NO;
860 | SWIFT_VERSION = 5.0;
861 | TARGETED_DEVICE_FAMILY = "1,2";
862 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Cuisine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Cuisine";
863 | };
864 | name = Debug;
865 | };
866 | 6AD4308E2C0F8081008BD892 /* Release */ = {
867 | isa = XCBuildConfiguration;
868 | buildSettings = {
869 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
870 | BUNDLE_LOADER = "$(TEST_HOST)";
871 | CODE_SIGN_STYLE = Automatic;
872 | CURRENT_PROJECT_VERSION = 1;
873 | DEVELOPMENT_TEAM = J9S8P7XX49;
874 | GENERATE_INFOPLIST_FILE = YES;
875 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
876 | MARKETING_VERSION = 1.0;
877 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.CuisineTests;
878 | PRODUCT_NAME = "$(TARGET_NAME)";
879 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
880 | SUPPORTS_MACCATALYST = NO;
881 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
882 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
883 | SWIFT_EMIT_LOC_STRINGS = NO;
884 | SWIFT_VERSION = 5.0;
885 | TARGETED_DEVICE_FAMILY = "1,2";
886 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Cuisine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Cuisine";
887 | };
888 | name = Release;
889 | };
890 | 6AD430902C0F8081008BD892 /* Debug */ = {
891 | isa = XCBuildConfiguration;
892 | buildSettings = {
893 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
894 | CODE_SIGN_STYLE = Automatic;
895 | CURRENT_PROJECT_VERSION = 1;
896 | DEVELOPMENT_TEAM = J9S8P7XX49;
897 | GENERATE_INFOPLIST_FILE = YES;
898 | MARKETING_VERSION = 1.0;
899 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.CuisineUITests;
900 | PRODUCT_NAME = "$(TARGET_NAME)";
901 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
902 | SUPPORTS_MACCATALYST = NO;
903 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
904 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
905 | SWIFT_EMIT_LOC_STRINGS = NO;
906 | SWIFT_VERSION = 5.0;
907 | TARGETED_DEVICE_FAMILY = "1,2";
908 | TEST_TARGET_NAME = Cuisine;
909 | };
910 | name = Debug;
911 | };
912 | 6AD430912C0F8081008BD892 /* Release */ = {
913 | isa = XCBuildConfiguration;
914 | buildSettings = {
915 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
916 | CODE_SIGN_STYLE = Automatic;
917 | CURRENT_PROJECT_VERSION = 1;
918 | DEVELOPMENT_TEAM = J9S8P7XX49;
919 | GENERATE_INFOPLIST_FILE = YES;
920 | MARKETING_VERSION = 1.0;
921 | PRODUCT_BUNDLE_IDENTIFIER = com.nolanfuchs.CuisineUITests;
922 | PRODUCT_NAME = "$(TARGET_NAME)";
923 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
924 | SUPPORTS_MACCATALYST = NO;
925 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
926 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
927 | SWIFT_EMIT_LOC_STRINGS = NO;
928 | SWIFT_VERSION = 5.0;
929 | TARGETED_DEVICE_FAMILY = "1,2";
930 | TEST_TARGET_NAME = Cuisine;
931 | };
932 | name = Release;
933 | };
934 | /* End XCBuildConfiguration section */
935 |
936 | /* Begin XCConfigurationList section */
937 | 6AD430602C0F8080008BD892 /* Build configuration list for PBXProject "Cuisine" */ = {
938 | isa = XCConfigurationList;
939 | buildConfigurations = (
940 | 6AD430872C0F8081008BD892 /* Debug */,
941 | 6AD430882C0F8081008BD892 /* Release */,
942 | );
943 | defaultConfigurationIsVisible = 0;
944 | defaultConfigurationName = Release;
945 | };
946 | 6AD430892C0F8081008BD892 /* Build configuration list for PBXNativeTarget "Cuisine" */ = {
947 | isa = XCConfigurationList;
948 | buildConfigurations = (
949 | 6AD4308A2C0F8081008BD892 /* Debug */,
950 | 6AD4308B2C0F8081008BD892 /* Release */,
951 | );
952 | defaultConfigurationIsVisible = 0;
953 | defaultConfigurationName = Release;
954 | };
955 | 6AD4308C2C0F8081008BD892 /* Build configuration list for PBXNativeTarget "CuisineTests" */ = {
956 | isa = XCConfigurationList;
957 | buildConfigurations = (
958 | 6AD4308D2C0F8081008BD892 /* Debug */,
959 | 6AD4308E2C0F8081008BD892 /* Release */,
960 | );
961 | defaultConfigurationIsVisible = 0;
962 | defaultConfigurationName = Release;
963 | };
964 | 6AD4308F2C0F8081008BD892 /* Build configuration list for PBXNativeTarget "CuisineUITests" */ = {
965 | isa = XCConfigurationList;
966 | buildConfigurations = (
967 | 6AD430902C0F8081008BD892 /* Debug */,
968 | 6AD430912C0F8081008BD892 /* Release */,
969 | );
970 | defaultConfigurationIsVisible = 0;
971 | defaultConfigurationName = Release;
972 | };
973 | /* End XCConfigurationList section */
974 | };
975 | rootObject = 6AD4305D2C0F8080008BD892 /* Project object */;
976 | }
977 |
--------------------------------------------------------------------------------