├── 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 | --------------------------------------------------------------------------------