From 13c24681fb0df9f109bd8bdf9575b6b6d90682a3 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 9 Jul 2021 06:26:05 +1000 Subject: [PATCH] Add source code for appendix C --- 19-appendix-c-uikit/.gitignore | 31 + .../Albertos.xcodeproj/project.pbxproj | 1595 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 34 + .../xcshareddata/xcschemes/Albertos.xcscheme | 109 ++ .../Albertos/AlertViewModel.swift | 7 + .../Albertos/AppCoordinator.swift | 84 + .../Albertos/AppDelegate.swift | 15 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 + .../Albertos/Assets.xcassets/Contents.json | 6 + .../Albertos/Collection+Safe.swift | 7 + ...oPaymentsProcessor+PaymentProcessing.swift | 16 + 19-appendix-c-uikit/Albertos/Info.plist | 62 + .../Albertos/MenuFetcher.swift | 17 + .../Albertos/MenuFetching.swift | 6 + .../Albertos/MenuGrouping.swift | 7 + 19-appendix-c-uikit/Albertos/MenuItem.swift | 11 + .../Albertos/MenuItemDetailView.swift | 40 + .../MenuItemDetailViewController.swift | 64 + .../Albertos/MenuItemDetailViewModel.swift | 49 + .../MenuListTableViewDataSource.swift | 56 + .../Albertos/MenuListTableViewDelegate.swift | 22 + .../Albertos/MenuListViewController.swift | 73 + .../Albertos/MenuListViewModel.swift | 30 + .../Albertos/MenuRowViewModel.swift | 8 + .../Albertos/MenuSection.swift | 7 + .../Albertos/NetworkFetching.swift | 7 + .../Albertos/Order+HippoPayments.swift | 4 + 19-appendix-c-uikit/Albertos/Order.swift | 8 + .../Albertos/OrderButton.ViewModel.swift | 16 + .../Albertos/OrderController.swift | 43 + .../Albertos/OrderDetailViewController.swift | 153 ++ .../Albertos/OrderDetailViewModel.swift | 72 + .../Albertos/OrderStoring.swift | 6 + .../Albertos/PaymentProcessing.swift | 6 + .../Preview Assets.xcassets/Contents.json | 6 + .../Albertos/SceneDelegate.swift | 33 + .../Albertos/UIButton+BigButtonStyle.swift | 49 + .../Albertos/UIColor+Custom.swift | 19 + .../Albertos/UIFont+Utils.swift | 13 + .../Albertos/UITableViewFooterLabel.swift | 34 + .../Albertos/UIView+AutoLayout.swift | 92 + .../Albertos/UIViewControllerPresenting.swift | 14 + .../Albertos/URLSession+NetworkFetching.swift | 11 + .../Albertos/UserDefaults+OrderStoring.swift | 22 + .../AlbertosTests/AppCoordinatorTests.swift | 98 + 19-appendix-c-uikit/AlbertosTests/Info.plist | 22 + .../AlbertosTests/MenuFetcherTests.swift | 57 + .../AlbertosTests/MenuFetchingStub.swift | 19 + .../AlbertosTests/MenuGroupingTests.swift | 44 + .../AlbertosTests/MenuItem+Fixture.swift | 13 + .../AlbertosTests/MenuItem+JSONFixture.swift | 20 + .../MenuItemAlternateJSONTests.swift | 57 + .../MenuItemDetail.ViewModelTests.swift | 84 + .../MenuItemDetailViewControllerTests.swift | 57 + .../MenuItemDetailViewTests.swift | 35 + .../AlbertosTests/MenuItemTests.swift | 68 + .../MenuList.ViewModelTests.swift | 74 + .../MenuListTableViewDataSourceTests.swift | 79 + .../MenuListViewControllerTests.swift | 22 + .../MenuRow.ViewModelTests.swift | 17 + .../AlbertosTests/MenuSection+Fixture.swift | 11 + .../MeunListTableViewDelegateTests.swift | 26 + .../AlbertosTests/NetworkFetchingStub.swift | 19 + .../OrderButtonViewModelTests.swift | 22 + .../AlbertosTests/OrderControllerTests.swift | 48 + .../OrderDetail.ViewModelTests.swift | 167 ++ .../AlbertosTests/OrderStoringFake.swift | 14 + .../AlbertosTests/OrderTests.swift | 26 + .../PaymentProcessingDummy.swift | 12 + .../AlbertosTests/PaymentProcessingSpy.swift | 13 + .../AlbertosTests/PaymentProcessingStub.swift | 19 + .../AlbertosTests/SceneDelegateTests.swift | 90 + .../AlbertosTests/TestError.swift | 3 + .../AlbertosTests/XCTestCase+JSON.swift | 11 + .../AlbertosTests/XCTestCase+Timeouts.swift | 8 + .../AlbertosTests/menu_item.json | 6 + .../AlbertosUITests/AlbertosUITests.swift | 14 + .../AlbertosUITests/Info.plist | 22 + 19-appendix-c-uikit/BuildPhases/xcsort | 58 + .../HippoAnalytics/HippoAnalytics.h | 18 + .../HippoAnalytics/HippoAnalyticsClient.swift | 12 + 19-appendix-c-uikit/HippoAnalytics/Info.plist | 22 + .../HippoPayments/HippoPayments.h | 9 + ...poPaymentsConfirmationViewController.swift | 50 + .../HippoPayments/HippoPaymentsError.swift | 3 + .../HippoPaymentsProcessor.swift | 21 + 19-appendix-c-uikit/HippoPayments/Info.plist | 22 + .../UIViewController+Presentation.swift | 11 + 91 files changed, 4611 insertions(+) create mode 100644 19-appendix-c-uikit/.gitignore create mode 100644 19-appendix-c-uikit/Albertos.xcodeproj/project.pbxproj create mode 100644 19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 19-appendix-c-uikit/Albertos.xcodeproj/xcshareddata/xcschemes/Albertos.xcscheme create mode 100644 19-appendix-c-uikit/Albertos/AlertViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/AppCoordinator.swift create mode 100644 19-appendix-c-uikit/Albertos/AppDelegate.swift create mode 100644 19-appendix-c-uikit/Albertos/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 19-appendix-c-uikit/Albertos/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 19-appendix-c-uikit/Albertos/Assets.xcassets/Contents.json create mode 100644 19-appendix-c-uikit/Albertos/Collection+Safe.swift create mode 100644 19-appendix-c-uikit/Albertos/HippoPaymentsProcessor+PaymentProcessing.swift create mode 100644 19-appendix-c-uikit/Albertos/Info.plist create mode 100644 19-appendix-c-uikit/Albertos/MenuFetcher.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuFetching.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuGrouping.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuItem.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuItemDetailView.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuItemDetailViewController.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuItemDetailViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuListTableViewDataSource.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuListTableViewDelegate.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuListViewController.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuListViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuRowViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/MenuSection.swift create mode 100644 19-appendix-c-uikit/Albertos/NetworkFetching.swift create mode 100644 19-appendix-c-uikit/Albertos/Order+HippoPayments.swift create mode 100644 19-appendix-c-uikit/Albertos/Order.swift create mode 100644 19-appendix-c-uikit/Albertos/OrderButton.ViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/OrderController.swift create mode 100644 19-appendix-c-uikit/Albertos/OrderDetailViewController.swift create mode 100644 19-appendix-c-uikit/Albertos/OrderDetailViewModel.swift create mode 100644 19-appendix-c-uikit/Albertos/OrderStoring.swift create mode 100644 19-appendix-c-uikit/Albertos/PaymentProcessing.swift create mode 100644 19-appendix-c-uikit/Albertos/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 19-appendix-c-uikit/Albertos/SceneDelegate.swift create mode 100644 19-appendix-c-uikit/Albertos/UIButton+BigButtonStyle.swift create mode 100644 19-appendix-c-uikit/Albertos/UIColor+Custom.swift create mode 100644 19-appendix-c-uikit/Albertos/UIFont+Utils.swift create mode 100644 19-appendix-c-uikit/Albertos/UITableViewFooterLabel.swift create mode 100644 19-appendix-c-uikit/Albertos/UIView+AutoLayout.swift create mode 100644 19-appendix-c-uikit/Albertos/UIViewControllerPresenting.swift create mode 100644 19-appendix-c-uikit/Albertos/URLSession+NetworkFetching.swift create mode 100644 19-appendix-c-uikit/Albertos/UserDefaults+OrderStoring.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/AppCoordinatorTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/Info.plist create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuFetcherTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuFetchingStub.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuGroupingTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItem+Fixture.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItem+JSONFixture.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItemAlternateJSONTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItemDetail.ViewModelTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewControllerTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuItemTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuList.ViewModelTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuListTableViewDataSourceTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuListViewControllerTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuRow.ViewModelTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MenuSection+Fixture.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/MeunListTableViewDelegateTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/NetworkFetchingStub.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/OrderButtonViewModelTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/OrderControllerTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/OrderDetail.ViewModelTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/OrderStoringFake.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/OrderTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/PaymentProcessingDummy.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/PaymentProcessingSpy.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/PaymentProcessingStub.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/SceneDelegateTests.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/TestError.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/XCTestCase+JSON.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/XCTestCase+Timeouts.swift create mode 100644 19-appendix-c-uikit/AlbertosTests/menu_item.json create mode 100644 19-appendix-c-uikit/AlbertosUITests/AlbertosUITests.swift create mode 100644 19-appendix-c-uikit/AlbertosUITests/Info.plist create mode 100755 19-appendix-c-uikit/BuildPhases/xcsort create mode 100644 19-appendix-c-uikit/HippoAnalytics/HippoAnalytics.h create mode 100644 19-appendix-c-uikit/HippoAnalytics/HippoAnalyticsClient.swift create mode 100644 19-appendix-c-uikit/HippoAnalytics/Info.plist create mode 100644 19-appendix-c-uikit/HippoPayments/HippoPayments.h create mode 100644 19-appendix-c-uikit/HippoPayments/HippoPaymentsConfirmationViewController.swift create mode 100644 19-appendix-c-uikit/HippoPayments/HippoPaymentsError.swift create mode 100644 19-appendix-c-uikit/HippoPayments/HippoPaymentsProcessor.swift create mode 100644 19-appendix-c-uikit/HippoPayments/Info.plist create mode 100644 19-appendix-c-uikit/HippoPayments/UIViewController+Presentation.swift diff --git a/19-appendix-c-uikit/.gitignore b/19-appendix-c-uikit/.gitignore new file mode 100644 index 0000000..f5ee019 --- /dev/null +++ b/19-appendix-c-uikit/.gitignore @@ -0,0 +1,31 @@ +#### joe made this: https://goel.io/joe + +#####=== Swift ===##### + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control +# +# Pods/ + diff --git a/19-appendix-c-uikit/Albertos.xcodeproj/project.pbxproj b/19-appendix-c-uikit/Albertos.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f76f0bc --- /dev/null +++ b/19-appendix-c-uikit/Albertos.xcodeproj/project.pbxproj @@ -0,0 +1,1595 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 3F058C7325CAA53400315239 /* AlertViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F058C7225CAA53400315239 /* AlertViewModel.swift */; }; + 3F058C9125CAA8A700315239 /* MenuSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F058C9025CAA8A700315239 /* MenuSection.swift */; }; + 3F058CB325CAAA1000315239 /* MenuListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F058CB225CAAA1000315239 /* MenuListViewModel.swift */; }; + 3F28631525A8E364004A579C /* HippoPayments.h in Headers */ = {isa = PBXBuildFile; fileRef = 3F28631325A8E364004A579C /* HippoPayments.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3F28631825A8E364004A579C /* HippoPayments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F28631125A8E364004A579C /* HippoPayments.framework */; }; + 3F28631925A8E364004A579C /* HippoPayments.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3F28631125A8E364004A579C /* HippoPayments.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3F28632825A8E3BE004A579C /* UIViewController+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28632725A8E3BE004A579C /* UIViewController+Presentation.swift */; }; + 3F28632D25A8E416004A579C /* HippoPaymentsProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28632C25A8E416004A579C /* HippoPaymentsProcessor.swift */; }; + 3F28633225A8E42C004A579C /* HippoPaymentsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28633125A8E42C004A579C /* HippoPaymentsError.swift */; }; + 3F28633725A8E44D004A579C /* HippoPaymentsConfirmationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28633625A8E44D004A579C /* HippoPaymentsConfirmationViewController.swift */; }; + 3F28635425A8E886004A579C /* PaymentProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28635325A8E886004A579C /* PaymentProcessing.swift */; }; + 3F28636525A8EABA004A579C /* HippoPaymentsProcessor+PaymentProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28636425A8EABA004A579C /* HippoPaymentsProcessor+PaymentProcessing.swift */; }; + 3F28636D25A8EAF1004A579C /* Order+HippoPayments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28636C25A8EAF1004A579C /* Order+HippoPayments.swift */; }; + 3F28637825A8ECE9004A579C /* PaymentProcessingSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F28637725A8ECE9004A579C /* PaymentProcessingSpy.swift */; }; + 3F2863B525AA29A9004A579C /* HippoAnalytics.h in Headers */ = {isa = PBXBuildFile; fileRef = 3F2863B325AA29A9004A579C /* HippoAnalytics.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3F2863B825AA29A9004A579C /* HippoAnalytics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2863B125AA29A9004A579C /* HippoAnalytics.framework */; }; + 3F2863B925AA29A9004A579C /* HippoAnalytics.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2863B125AA29A9004A579C /* HippoAnalytics.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3F2863C225AA29B9004A579C /* HippoAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2863C125AA29B9004A579C /* HippoAnalyticsClient.swift */; }; + 3F2864EF25AA3FC8004A579C /* PaymentProcessingStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2864EE25AA3FC8004A579C /* PaymentProcessingStub.swift */; }; + 3F2864F925AA41D5004A579C /* XCTestCase+Timeouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2864F825AA41D5004A579C /* XCTestCase+Timeouts.swift */; }; + 3F40EE5625BA4D4D0067FBA7 /* UIEdgeInsets+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EE5525BA4D4D0067FBA7 /* UIEdgeInsets+Convenience.swift */; }; + 3F40EE9625BB4C680067FBA7 /* OrderDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EE9525BB4C680067FBA7 /* OrderDetailViewController.swift */; }; + 3F40EEA025BB55970067FBA7 /* UIButton+BigButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EE9F25BB55970067FBA7 /* UIButton+BigButtonStyle.swift */; }; + 3F40EEAD25BBB9AD0067FBA7 /* Collection+Safe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC9D8F6256244DD00BF115D /* Collection+Safe.swift */; }; + 3F40EECD25BBC36B0067FBA7 /* MenuItemDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EECC25BBC36B0067FBA7 /* MenuItemDetailView.swift */; }; + 3F40EF0D25BCA6F70067FBA7 /* SceneDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EF0C25BCA6F60067FBA7 /* SceneDelegateTests.swift */; }; + 3F40EF1F25BCA8150067FBA7 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = 3F40EF1E25BCA8150067FBA7 /* Nimble */; }; + 3F40EF2925BCA9EA0067FBA7 /* UITableViewFooterLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EF2825BCA9EA0067FBA7 /* UITableViewFooterLabel.swift */; }; + 3F40EF4A25BCB7100067FBA7 /* MenuItemDetailViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F40EF4925BCB7100067FBA7 /* MenuItemDetailViewControllerTests.swift */; }; + 3F437A5525BE0625002BB2F8 /* UIViewControllerPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F437A5425BE0625002BB2F8 /* UIViewControllerPresenting.swift */; }; + 3F437A6C25BF561F002BB2F8 /* AppCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F437A6B25BF561F002BB2F8 /* AppCoordinatorTests.swift */; }; + 3F567DFB267BF0B9000A5361 /* MenuFetchingStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F567DFA267BF0B9000A5361 /* MenuFetchingStub.swift */; }; + 3F567DFD267BF5A7000A5361 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F567DFC267BF5A7000A5361 /* TestError.swift */; }; + 3F50065325A247BE00E25EED /* Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50065225A247BE00E25EED /* Order.swift */; }; + 3F50065B25A2491300E25EED /* OrderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50065A25A2491300E25EED /* OrderTests.swift */; }; + 3F50066325A24B7000E25EED /* OrderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50066225A24B7000E25EED /* OrderController.swift */; }; + 3F50066B25A24C2900E25EED /* OrderControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50066A25A24C2900E25EED /* OrderControllerTests.swift */; }; + 3F50068925A253A400E25EED /* MenuItemDetail.ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50068825A253A400E25EED /* MenuItemDetail.ViewModelTests.swift */; }; + 3F50071525A39A4900E25EED /* MenuFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50071425A39A4900E25EED /* MenuFetcher.swift */; }; + 3F50074325A4EB8B00E25EED /* UIColor+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50074225A4EB8B00E25EED /* UIColor+Custom.swift */; }; + 3F50076425A4EFFA00E25EED /* OrderDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50076325A4EFFA00E25EED /* OrderDetailViewModel.swift */; }; + 3F50076C25A4F10100E25EED /* OrderDetail.ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50076B25A4F10100E25EED /* OrderDetail.ViewModelTests.swift */; }; + 3F50077A25A4F1B700E25EED /* OrderButtonViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50077925A4F1B700E25EED /* OrderButtonViewModelTests.swift */; }; + 3F50079925A4F46D00E25EED /* OrderButton.ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50079825A4F46C00E25EED /* OrderButton.ViewModel.swift */; }; + 3F56EDFB2571E16200311F1A /* MenuItemAlternateJSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F56EDFA2571E16200311F1A /* MenuItemAlternateJSONTests.swift */; }; + 3F56EE062571E96B00311F1A /* MenuFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F56EE052571E96B00311F1A /* MenuFetcherTests.swift */; }; + 3F56EE11257330FF00311F1A /* NetworkFetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F56EE10257330FF00311F1A /* NetworkFetching.swift */; }; + 3F56EE19257331BB00311F1A /* URLSession+NetworkFetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F56EE18257331BB00311F1A /* URLSession+NetworkFetching.swift */; }; + 3F5F70A32681F4610096B7E5 /* NetworkFetchingStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5F70A22681F4610096B7E5 /* NetworkFetchingStub.swift */; }; + 3F67592D25B8AF6B00564AA5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F67592C25B8AF6B00564AA5 /* AppDelegate.swift */; }; + 3F67593B25B8B0B500564AA5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F67593A25B8B0B500564AA5 /* SceneDelegate.swift */; }; + 3F83FFE8259D66770017214F /* MenuRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F83FFE7259D66770017214F /* MenuRowViewModel.swift */; }; + 3FB6A66D25C0A9FA00505EA6 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB6A66C25C0A9FA00505EA6 /* AppCoordinator.swift */; }; + 3FC30C5725C22C2B00C0471C /* MenuItemDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC30C5625C22C2B00C0471C /* MenuItemDetailViewModel.swift */; }; + 3FC30C6125C22CC000C0471C /* MenuItemDetailViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC30C6025C22CC000C0471C /* MenuItemDetailViewTests.swift */; }; + 3FC30C6B25C22FCA00C0471C /* UIFont+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC30C6A25C22FCA00C0471C /* UIFont+Utils.swift */; }; + 3FC30C7525C23A4000C0471C /* MenuListTableViewDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC30C7425C23A4000C0471C /* MenuListTableViewDataSourceTests.swift */; }; + 3FC30C7F25C23E4200C0471C /* MenuListTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC30C7E25C23E4200C0471C /* MenuListTableViewDataSource.swift */; }; + 3FC9D8B52562389F00BF115D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3FC9D8B42562389F00BF115D /* Assets.xcassets */; }; + 3FC9D8B82562389F00BF115D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3FC9D8B72562389F00BF115D /* Preview Assets.xcassets */; }; + 3FC9D8CE256238A000BF115D /* AlbertosUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC9D8CD256238A000BF115D /* AlbertosUITests.swift */; }; + 3FC9D8DD2562397500BF115D /* MenuGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC9D8DC2562397500BF115D /* MenuGroupingTests.swift */; }; + 3FC9D8E825623AB500BF115D /* MenuGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC9D8E725623AB500BF115D /* MenuGrouping.swift */; }; + 3FC9D8ED25623AD900BF115D /* MenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC9D8EC25623AD900BF115D /* MenuItem.swift */; }; + 3FE010D825AB8B6D00D02C2E /* OrderStoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE010D725AB8B6D00D02C2E /* OrderStoring.swift */; }; + 3FE010E525AB8C1C00D02C2E /* UserDefaults+OrderStoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE010E425AB8C1C00D02C2E /* UserDefaults+OrderStoring.swift */; }; + 3FE0114325AB937400D02C2E /* OrderStoringFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE0114225AB937400D02C2E /* OrderStoringFake.swift */; }; + 3FE0120225ACCB1200D02C2E /* PaymentProcessingDummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE0120125ACCB1200D02C2E /* PaymentProcessingDummy.swift */; }; + 3FE48A9725C5E2FD00A46560 /* MenuListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE48A9625C5E2FD00A46560 /* MenuListViewControllerTests.swift */; }; + 3FE48AB525C5E7BA00A46560 /* MenuListTableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE48AB425C5E7BA00A46560 /* MenuListTableViewDelegate.swift */; }; + 3FE48ABF25C5E7DF00A46560 /* MeunListTableViewDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE48ABE25C5E7DF00A46560 /* MeunListTableViewDelegateTests.swift */; }; + 3FE48B3E25C7374E00A46560 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE48B3D25C7374E00A46560 /* TestError.swift */; }; + 3FF54A8A25BA0D8200C8F73B /* MenuListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF54A8925BA0D8200C8F73B /* MenuListViewController.swift */; }; + 3FF54AA825BA0FBE00C8F73B /* UIView+AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF54AA725BA0FBE00C8F73B /* UIView+AutoLayout.swift */; }; + 3FF54AB225BA39EF00C8F73B /* MenuItemDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF54AB125BA39EF00C8F73B /* MenuItemDetailViewController.swift */; }; + 3FF61B47257096E0003953C6 /* MenuItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF61B46257096E0003953C6 /* MenuItemTests.swift */; }; + 3FF61B572571DBEA003953C6 /* menu_item.json in Resources */ = {isa = PBXBuildFile; fileRef = 3FF61B562571DBEA003953C6 /* menu_item.json */; }; + 3FF61B5F2571DD29003953C6 /* XCTestCase+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF61B5E2571DD29003953C6 /* XCTestCase+JSON.swift */; }; + 3FF61B672571DE75003953C6 /* MenuItem+JSONFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF61B662571DE75003953C6 /* MenuItem+JSONFixture.swift */; }; + 3FF6741D256A708200BD74AF /* MenuItem+Fixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF6741C256A708200BD74AF /* MenuItem+Fixture.swift */; }; + 3FF67425256A777000BD74AF /* MenuSection+Fixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF67424256A777000BD74AF /* MenuSection+Fixture.swift */; }; + 3FF6743B256A7C2200BD74AF /* MenuRow.ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF6743A256A7C2200BD74AF /* MenuRow.ViewModelTests.swift */; }; + 3FF67475256CAA5900BD74AF /* MenuList.ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF67474256CAA5900BD74AF /* MenuList.ViewModelTests.swift */; }; + 3FF67486256E800E00BD74AF /* MenuFetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF67485256E800E00BD74AF /* MenuFetching.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 3F28631625A8E364004A579C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3FC9D8632562374800BF115D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3F28631025A8E364004A579C; + remoteInfo = HippoPayments; + }; + 3F2863B625AA29A9004A579C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3FC9D8632562374800BF115D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3F2863B025AA29A9004A579C; + remoteInfo = HippoAnalytics; + }; + 3FC9D8BF256238A000BF115D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3FC9D8632562374800BF115D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3FC9D8AC2562389E00BF115D; + remoteInfo = Albertos; + }; + 3FC9D8CA256238A000BF115D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3FC9D8632562374800BF115D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3FC9D8AC2562389E00BF115D; + remoteInfo = Albertos; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 3F28631A25A8E364004A579C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 3F2863B925AA29A9004A579C /* HippoAnalytics.framework in Embed Frameworks */, + 3F28631925A8E364004A579C /* HippoPayments.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 3F058C7225CAA53400315239 /* AlertViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewModel.swift; sourceTree = ""; }; + 3F058C9025CAA8A700315239 /* MenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSection.swift; sourceTree = ""; }; + 3F058CB225CAAA1000315239 /* MenuListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListViewModel.swift; sourceTree = ""; }; + 3F28631125A8E364004A579C /* HippoPayments.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = HippoPayments.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3F28631325A8E364004A579C /* HippoPayments.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HippoPayments.h; sourceTree = ""; }; + 3F28631425A8E364004A579C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3F28632725A8E3BE004A579C /* UIViewController+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Presentation.swift"; sourceTree = ""; }; + 3F28632C25A8E416004A579C /* HippoPaymentsProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HippoPaymentsProcessor.swift; sourceTree = ""; }; + 3F28633125A8E42C004A579C /* HippoPaymentsError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HippoPaymentsError.swift; sourceTree = ""; }; + 3F28633625A8E44D004A579C /* HippoPaymentsConfirmationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HippoPaymentsConfirmationViewController.swift; sourceTree = ""; }; + 3F28635325A8E886004A579C /* PaymentProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProcessing.swift; sourceTree = ""; }; + 3F28636425A8EABA004A579C /* HippoPaymentsProcessor+PaymentProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HippoPaymentsProcessor+PaymentProcessing.swift"; sourceTree = ""; }; + 3F28636C25A8EAF1004A579C /* Order+HippoPayments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Order+HippoPayments.swift"; sourceTree = ""; }; + 3F28637725A8ECE9004A579C /* PaymentProcessingSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProcessingSpy.swift; sourceTree = ""; }; + 3F2863B125AA29A9004A579C /* HippoAnalytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = HippoAnalytics.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3F2863B325AA29A9004A579C /* HippoAnalytics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HippoAnalytics.h; sourceTree = ""; }; + 3F2863B425AA29A9004A579C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3F2863C125AA29B9004A579C /* HippoAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HippoAnalyticsClient.swift; sourceTree = ""; }; + 3F2864EE25AA3FC8004A579C /* PaymentProcessingStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProcessingStub.swift; sourceTree = ""; }; + 3F2864F825AA41D5004A579C /* XCTestCase+Timeouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Timeouts.swift"; sourceTree = ""; }; + 3F40EE5525BA4D4D0067FBA7 /* UIEdgeInsets+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIEdgeInsets+Convenience.swift"; sourceTree = ""; }; + 3F40EE9525BB4C680067FBA7 /* OrderDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailViewController.swift; sourceTree = ""; }; + 3F40EE9F25BB55970067FBA7 /* UIButton+BigButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+BigButtonStyle.swift"; sourceTree = ""; }; + 3F40EECC25BBC36B0067FBA7 /* MenuItemDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetailView.swift; sourceTree = ""; }; + 3F40EF0C25BCA6F60067FBA7 /* SceneDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegateTests.swift; sourceTree = ""; }; + 3F40EF2825BCA9EA0067FBA7 /* UITableViewFooterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewFooterLabel.swift; sourceTree = ""; }; + 3F40EF4925BCB7100067FBA7 /* MenuItemDetailViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetailViewControllerTests.swift; sourceTree = ""; }; + 3F437A5425BE0625002BB2F8 /* UIViewControllerPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerPresenting.swift; sourceTree = ""; }; + 3F437A6B25BF561F002BB2F8 /* AppCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorTests.swift; sourceTree = ""; }; + 3F567DFA267BF0B9000A5361 /* MenuFetchingStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuFetchingStub.swift; sourceTree = ""; }; + 3F567DFC267BF5A7000A5361 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; + 3F50065225A247BE00E25EED /* Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Order.swift; sourceTree = ""; }; + 3F50065A25A2491300E25EED /* OrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTests.swift; sourceTree = ""; }; + 3F50066225A24B7000E25EED /* OrderController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderController.swift; sourceTree = ""; }; + 3F50066A25A24C2900E25EED /* OrderControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderControllerTests.swift; sourceTree = ""; }; + 3F50068825A253A400E25EED /* MenuItemDetail.ViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetail.ViewModelTests.swift; sourceTree = ""; }; + 3F50071425A39A4900E25EED /* MenuFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuFetcher.swift; sourceTree = ""; }; + 3F50074225A4EB8B00E25EED /* UIColor+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Custom.swift"; sourceTree = ""; }; + 3F50076325A4EFFA00E25EED /* OrderDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailViewModel.swift; sourceTree = ""; }; + 3F50076B25A4F10100E25EED /* OrderDetail.ViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetail.ViewModelTests.swift; sourceTree = ""; }; + 3F50077925A4F1B700E25EED /* OrderButtonViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderButtonViewModelTests.swift; sourceTree = ""; }; + 3F50079825A4F46C00E25EED /* OrderButton.ViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrderButton.ViewModel.swift; sourceTree = ""; }; + 3F56EDFA2571E16200311F1A /* MenuItemAlternateJSONTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemAlternateJSONTests.swift; sourceTree = ""; }; + 3F56EE052571E96B00311F1A /* MenuFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuFetcherTests.swift; sourceTree = ""; }; + 3F56EE10257330FF00311F1A /* NetworkFetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFetching.swift; sourceTree = ""; }; + 3F56EE18257331BB00311F1A /* URLSession+NetworkFetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+NetworkFetching.swift"; sourceTree = ""; }; + 3F5F70A22681F4610096B7E5 /* NetworkFetchingStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkFetchingStub.swift; sourceTree = ""; }; + 3F67592C25B8AF6B00564AA5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3F67593A25B8B0B500564AA5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 3F83FFE7259D66770017214F /* MenuRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuRowViewModel.swift; sourceTree = ""; }; + 3FB6A66C25C0A9FA00505EA6 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 3FC30C5625C22C2B00C0471C /* MenuItemDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetailViewModel.swift; sourceTree = ""; }; + 3FC30C6025C22CC000C0471C /* MenuItemDetailViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetailViewTests.swift; sourceTree = ""; }; + 3FC30C6A25C22FCA00C0471C /* UIFont+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Utils.swift"; sourceTree = ""; }; + 3FC30C7425C23A4000C0471C /* MenuListTableViewDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListTableViewDataSourceTests.swift; sourceTree = ""; }; + 3FC30C7E25C23E4200C0471C /* MenuListTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListTableViewDataSource.swift; sourceTree = ""; }; + 3FC9D8AD2562389E00BF115D /* Albertos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Albertos.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FC9D8B42562389F00BF115D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 3FC9D8B72562389F00BF115D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 3FC9D8B92562389F00BF115D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3FC9D8BE2562389F00BF115D /* AlbertosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AlbertosTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FC9D8C4256238A000BF115D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3FC9D8C9256238A000BF115D /* AlbertosUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AlbertosUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FC9D8CD256238A000BF115D /* AlbertosUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbertosUITests.swift; sourceTree = ""; }; + 3FC9D8CF256238A000BF115D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3FC9D8DC2562397500BF115D /* MenuGroupingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuGroupingTests.swift; sourceTree = ""; }; + 3FC9D8E725623AB500BF115D /* MenuGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuGrouping.swift; sourceTree = ""; }; + 3FC9D8EC25623AD900BF115D /* MenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItem.swift; sourceTree = ""; }; + 3FC9D8F6256244DD00BF115D /* Collection+Safe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Safe.swift"; sourceTree = ""; }; + 3FE010D725AB8B6D00D02C2E /* OrderStoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderStoring.swift; sourceTree = ""; }; + 3FE010E425AB8C1C00D02C2E /* UserDefaults+OrderStoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OrderStoring.swift"; sourceTree = ""; }; + 3FE0114225AB937400D02C2E /* OrderStoringFake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderStoringFake.swift; sourceTree = ""; }; + 3FE0120125ACCB1200D02C2E /* PaymentProcessingDummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProcessingDummy.swift; sourceTree = ""; }; + 3FE48A9625C5E2FD00A46560 /* MenuListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListViewControllerTests.swift; sourceTree = ""; }; + 3FE48AB425C5E7BA00A46560 /* MenuListTableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListTableViewDelegate.swift; sourceTree = ""; }; + 3FE48ABE25C5E7DF00A46560 /* MeunListTableViewDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeunListTableViewDelegateTests.swift; sourceTree = ""; }; + 3FE48B3D25C7374E00A46560 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; + 3FF54A8925BA0D8200C8F73B /* MenuListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListViewController.swift; sourceTree = ""; }; + 3FF54AA725BA0FBE00C8F73B /* UIView+AutoLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+AutoLayout.swift"; sourceTree = ""; }; + 3FF54AB125BA39EF00C8F73B /* MenuItemDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemDetailViewController.swift; sourceTree = ""; }; + 3FF61B46257096E0003953C6 /* MenuItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemTests.swift; sourceTree = ""; }; + 3FF61B562571DBEA003953C6 /* menu_item.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = menu_item.json; sourceTree = ""; }; + 3FF61B5E2571DD29003953C6 /* XCTestCase+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+JSON.swift"; sourceTree = ""; }; + 3FF61B662571DE75003953C6 /* MenuItem+JSONFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MenuItem+JSONFixture.swift"; sourceTree = ""; }; + 3FF6741C256A708200BD74AF /* MenuItem+Fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MenuItem+Fixture.swift"; sourceTree = ""; }; + 3FF67424256A777000BD74AF /* MenuSection+Fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MenuSection+Fixture.swift"; sourceTree = ""; }; + 3FF6743A256A7C2200BD74AF /* MenuRow.ViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuRow.ViewModelTests.swift; sourceTree = ""; }; + 3FF67474256CAA5900BD74AF /* MenuList.ViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuList.ViewModelTests.swift; sourceTree = ""; }; + 3FF67485256E800E00BD74AF /* MenuFetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuFetching.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3F28630E25A8E364004A579C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F2863AE25AA29A9004A579C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8AA2562389E00BF115D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F2863B825AA29A9004A579C /* HippoAnalytics.framework in Frameworks */, + 3F28631825A8E364004A579C /* HippoPayments.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8BB2562389F00BF115D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F40EF1F25BCA8150067FBA7 /* Nimble in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8C6256238A000BF115D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3F28631225A8E364004A579C /* HippoPayments */ = { + isa = PBXGroup; + children = ( + 3F28631325A8E364004A579C /* HippoPayments.h */, + 3F28633625A8E44D004A579C /* HippoPaymentsConfirmationViewController.swift */, + 3F28633125A8E42C004A579C /* HippoPaymentsError.swift */, + 3F28632C25A8E416004A579C /* HippoPaymentsProcessor.swift */, + 3F28631425A8E364004A579C /* Info.plist */, + 3F28632725A8E3BE004A579C /* UIViewController+Presentation.swift */, + ); + path = HippoPayments; + sourceTree = ""; + }; + 3F2863B225AA29A9004A579C /* HippoAnalytics */ = { + isa = PBXGroup; + children = ( + 3F2863B325AA29A9004A579C /* HippoAnalytics.h */, + 3F2863C125AA29B9004A579C /* HippoAnalyticsClient.swift */, + 3F2863B425AA29A9004A579C /* Info.plist */, + ); + path = HippoAnalytics; + sourceTree = ""; + }; + 3F40EF1D25BCA8150067FBA7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 3FC9D8622562374800BF115D = { + isa = PBXGroup; + children = ( + 3FC9D8AF2562389E00BF115D /* Albertos */, + 3FC9D8C1256238A000BF115D /* AlbertosTests */, + 3FC9D8CC256238A000BF115D /* AlbertosUITests */, + 3F40EF1D25BCA8150067FBA7 /* Frameworks */, + 3F2863B225AA29A9004A579C /* HippoAnalytics */, + 3F28631225A8E364004A579C /* HippoPayments */, + 3FC9D8AE2562389E00BF115D /* Products */, + 3F40EE5525BA4D4D0067FBA7 /* UIEdgeInsets+Convenience.swift */, + ); + sourceTree = ""; + }; + 3FC9D8AE2562389E00BF115D /* Products */ = { + isa = PBXGroup; + children = ( + 3FC9D8AD2562389E00BF115D /* Albertos.app */, + 3FC9D8BE2562389F00BF115D /* AlbertosTests.xctest */, + 3FC9D8C9256238A000BF115D /* AlbertosUITests.xctest */, + 3F2863B125AA29A9004A579C /* HippoAnalytics.framework */, + 3F28631125A8E364004A579C /* HippoPayments.framework */, + ); + name = Products; + sourceTree = ""; + }; + 3FC9D8AF2562389E00BF115D /* Albertos */ = { + isa = PBXGroup; + children = ( + 3F058C7225CAA53400315239 /* AlertViewModel.swift */, + 3FB6A66C25C0A9FA00505EA6 /* AppCoordinator.swift */, + 3F67592C25B8AF6B00564AA5 /* AppDelegate.swift */, + 3FC9D8B42562389F00BF115D /* Assets.xcassets */, + 3FC9D8F6256244DD00BF115D /* Collection+Safe.swift */, + 3F28636425A8EABA004A579C /* HippoPaymentsProcessor+PaymentProcessing.swift */, + 3FC9D8B92562389F00BF115D /* Info.plist */, + 3F50071425A39A4900E25EED /* MenuFetcher.swift */, + 3FF67485256E800E00BD74AF /* MenuFetching.swift */, + 3FC9D8E725623AB500BF115D /* MenuGrouping.swift */, + 3FC9D8EC25623AD900BF115D /* MenuItem.swift */, + 3F40EECC25BBC36B0067FBA7 /* MenuItemDetailView.swift */, + 3FF54AB125BA39EF00C8F73B /* MenuItemDetailViewController.swift */, + 3FC30C5625C22C2B00C0471C /* MenuItemDetailViewModel.swift */, + 3FC30C7E25C23E4200C0471C /* MenuListTableViewDataSource.swift */, + 3FE48AB425C5E7BA00A46560 /* MenuListTableViewDelegate.swift */, + 3FF54A8925BA0D8200C8F73B /* MenuListViewController.swift */, + 3F058CB225CAAA1000315239 /* MenuListViewModel.swift */, + 3F83FFE7259D66770017214F /* MenuRowViewModel.swift */, + 3F058C9025CAA8A700315239 /* MenuSection.swift */, + 3F56EE10257330FF00311F1A /* NetworkFetching.swift */, + 3F28636C25A8EAF1004A579C /* Order+HippoPayments.swift */, + 3F50065225A247BE00E25EED /* Order.swift */, + 3F50079825A4F46C00E25EED /* OrderButton.ViewModel.swift */, + 3F50066225A24B7000E25EED /* OrderController.swift */, + 3F40EE9525BB4C680067FBA7 /* OrderDetailViewController.swift */, + 3F50076325A4EFFA00E25EED /* OrderDetailViewModel.swift */, + 3FE010D725AB8B6D00D02C2E /* OrderStoring.swift */, + 3F28635325A8E886004A579C /* PaymentProcessing.swift */, + 3FC9D8B62562389F00BF115D /* Preview Content */, + 3F67593A25B8B0B500564AA5 /* SceneDelegate.swift */, + 3F40EE9F25BB55970067FBA7 /* UIButton+BigButtonStyle.swift */, + 3F50074225A4EB8B00E25EED /* UIColor+Custom.swift */, + 3FC30C6A25C22FCA00C0471C /* UIFont+Utils.swift */, + 3F40EF2825BCA9EA0067FBA7 /* UITableViewFooterLabel.swift */, + 3FF54AA725BA0FBE00C8F73B /* UIView+AutoLayout.swift */, + 3F437A5425BE0625002BB2F8 /* UIViewControllerPresenting.swift */, + 3F56EE18257331BB00311F1A /* URLSession+NetworkFetching.swift */, + 3FE010E425AB8C1C00D02C2E /* UserDefaults+OrderStoring.swift */, + ); + path = Albertos; + sourceTree = ""; + }; + 3FC9D8B62562389F00BF115D /* Preview Content */ = { + isa = PBXGroup; + children = ( + 3FC9D8B72562389F00BF115D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 3FC9D8C1256238A000BF115D /* AlbertosTests */ = { + isa = PBXGroup; + children = ( + 3F437A6B25BF561F002BB2F8 /* AppCoordinatorTests.swift */, + 3FC9D8C4256238A000BF115D /* Info.plist */, + 3F56EE052571E96B00311F1A /* MenuFetcherTests.swift */, + 3F567DFA267BF0B9000A5361 /* MenuFetchingStub.swift */, + 3FC9D8DC2562397500BF115D /* MenuGroupingTests.swift */, + 3FF6741C256A708200BD74AF /* MenuItem+Fixture.swift */, + 3FF61B662571DE75003953C6 /* MenuItem+JSONFixture.swift */, + 3F56EDFA2571E16200311F1A /* MenuItemAlternateJSONTests.swift */, + 3F50068825A253A400E25EED /* MenuItemDetail.ViewModelTests.swift */, + 3F40EF4925BCB7100067FBA7 /* MenuItemDetailViewControllerTests.swift */, + 3FC30C6025C22CC000C0471C /* MenuItemDetailViewTests.swift */, + 3FF61B46257096E0003953C6 /* MenuItemTests.swift */, + 3FF67474256CAA5900BD74AF /* MenuList.ViewModelTests.swift */, + 3FC30C7425C23A4000C0471C /* MenuListTableViewDataSourceTests.swift */, + 3FE48A9625C5E2FD00A46560 /* MenuListViewControllerTests.swift */, + 3FF6743A256A7C2200BD74AF /* MenuRow.ViewModelTests.swift */, + 3FF67424256A777000BD74AF /* MenuSection+Fixture.swift */, + 3FE48ABE25C5E7DF00A46560 /* MeunListTableViewDelegateTests.swift */, + 3F5F70A22681F4610096B7E5 /* NetworkFetchingStub.swift */, + 3F50077925A4F1B700E25EED /* OrderButtonViewModelTests.swift */, + 3F50066A25A24C2900E25EED /* OrderControllerTests.swift */, + 3F50076B25A4F10100E25EED /* OrderDetail.ViewModelTests.swift */, + 3FE0114225AB937400D02C2E /* OrderStoringFake.swift */, + 3F50065A25A2491300E25EED /* OrderTests.swift */, + 3FE0120125ACCB1200D02C2E /* PaymentProcessingDummy.swift */, + 3F28637725A8ECE9004A579C /* PaymentProcessingSpy.swift */, + 3F2864EE25AA3FC8004A579C /* PaymentProcessingStub.swift */, + 3F40EF0C25BCA6F60067FBA7 /* SceneDelegateTests.swift */, + 3F567DFC267BF5A7000A5361 /* TestError.swift */, + 3FF61B5E2571DD29003953C6 /* XCTestCase+JSON.swift */, + 3F2864F825AA41D5004A579C /* XCTestCase+Timeouts.swift */, + 3FF61B562571DBEA003953C6 /* menu_item.json */, + ); + path = AlbertosTests; + sourceTree = ""; + }; + 3FC9D8CC256238A000BF115D /* AlbertosUITests */ = { + isa = PBXGroup; + children = ( + 3FC9D8CD256238A000BF115D /* AlbertosUITests.swift */, + 3FC9D8CF256238A000BF115D /* Info.plist */, + ); + path = AlbertosUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 3F28630C25A8E364004A579C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F28631525A8E364004A579C /* HippoPayments.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F2863AC25AA29A9004A579C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F2863B525AA29A9004A579C /* HippoAnalytics.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 3F28631025A8E364004A579C /* HippoPayments */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3F28631D25A8E364004A579C /* Build configuration list for PBXNativeTarget "HippoPayments" */; + buildPhases = ( + 3F28630C25A8E364004A579C /* Headers */, + 3F28630D25A8E364004A579C /* Sources */, + 3F28630E25A8E364004A579C /* Frameworks */, + 3F28630F25A8E364004A579C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HippoPayments; + productName = HippoPayments; + productReference = 3F28631125A8E364004A579C /* HippoPayments.framework */; + productType = "com.apple.product-type.framework"; + }; + 3F2863B025AA29A9004A579C /* HippoAnalytics */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3F2863BC25AA29A9004A579C /* Build configuration list for PBXNativeTarget "HippoAnalytics" */; + buildPhases = ( + 3F2863AC25AA29A9004A579C /* Headers */, + 3F2863AD25AA29A9004A579C /* Sources */, + 3F2863AE25AA29A9004A579C /* Frameworks */, + 3F2863AF25AA29A9004A579C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HippoAnalytics; + productName = HippoAnalytics; + productReference = 3F2863B125AA29A9004A579C /* HippoAnalytics.framework */; + productType = "com.apple.product-type.framework"; + }; + 3FC9D8AC2562389E00BF115D /* Albertos */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3FC9D8D6256238A000BF115D /* Build configuration list for PBXNativeTarget "Albertos" */; + buildPhases = ( + 3FC9D8A92562389E00BF115D /* Sources */, + 3FC9D8AA2562389E00BF115D /* Frameworks */, + 3FC9D8AB2562389E00BF115D /* Resources */, + 3FC9D901256248DF00BF115D /* Sort Project Alphabetically */, + 3F28631A25A8E364004A579C /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 3F28631725A8E364004A579C /* PBXTargetDependency */, + 3F2863B725AA29A9004A579C /* PBXTargetDependency */, + ); + name = Albertos; + packageProductDependencies = ( + ); + productName = Albertos; + productReference = 3FC9D8AD2562389E00BF115D /* Albertos.app */; + productType = "com.apple.product-type.application"; + }; + 3FC9D8BD2562389F00BF115D /* AlbertosTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3FC9D8D7256238A000BF115D /* Build configuration list for PBXNativeTarget "AlbertosTests" */; + buildPhases = ( + 3FC9D8BA2562389F00BF115D /* Sources */, + 3FC9D8BB2562389F00BF115D /* Frameworks */, + 3FC9D8BC2562389F00BF115D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3FC9D8C0256238A000BF115D /* PBXTargetDependency */, + ); + name = AlbertosTests; + packageProductDependencies = ( + 3F40EF1E25BCA8150067FBA7 /* Nimble */, + ); + productName = AlbertosTests; + productReference = 3FC9D8BE2562389F00BF115D /* AlbertosTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 3FC9D8C8256238A000BF115D /* AlbertosUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3FC9D8D8256238A000BF115D /* Build configuration list for PBXNativeTarget "AlbertosUITests" */; + buildPhases = ( + 3FC9D8C5256238A000BF115D /* Sources */, + 3FC9D8C6256238A000BF115D /* Frameworks */, + 3FC9D8C7256238A000BF115D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3FC9D8CB256238A000BF115D /* PBXTargetDependency */, + ); + name = AlbertosUITests; + productName = AlbertosUITests; + productReference = 3FC9D8C9256238A000BF115D /* AlbertosUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3FC9D8632562374800BF115D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1220; + LastUpgradeCheck = 1230; + TargetAttributes = { + 3F28631025A8E364004A579C = { + CreatedOnToolsVersion = 12.3; + LastSwiftMigration = 1230; + }; + 3F2863B025AA29A9004A579C = { + CreatedOnToolsVersion = 12.3; + LastSwiftMigration = 1230; + }; + 3FC9D8AC2562389E00BF115D = { + CreatedOnToolsVersion = 12.2; + }; + 3FC9D8BD2562389F00BF115D = { + CreatedOnToolsVersion = 12.2; + TestTargetID = 3FC9D8AC2562389E00BF115D; + }; + 3FC9D8C8256238A000BF115D = { + CreatedOnToolsVersion = 12.2; + TestTargetID = 3FC9D8AC2562389E00BF115D; + }; + }; + }; + buildConfigurationList = 3FC9D8662562374800BF115D /* Build configuration list for PBXProject "Albertos" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 3FC9D8622562374800BF115D; + packageReferences = ( + 3F40EF1625BCA7F90067FBA7 /* XCRemoteSwiftPackageReference "Nimble" */, + ); + productRefGroup = 3FC9D8AE2562389E00BF115D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 3FC9D8AC2562389E00BF115D /* Albertos */, + 3FC9D8BD2562389F00BF115D /* AlbertosTests */, + 3FC9D8C8256238A000BF115D /* AlbertosUITests */, + 3F28631025A8E364004A579C /* HippoPayments */, + 3F2863B025AA29A9004A579C /* HippoAnalytics */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3F28630F25A8E364004A579C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F2863AF25AA29A9004A579C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8AB2562389E00BF115D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FC9D8B52562389F00BF115D /* Assets.xcassets in Resources */, + 3FC9D8B82562389F00BF115D /* Preview Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8BC2562389F00BF115D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FF61B572571DBEA003953C6 /* menu_item.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8C7256238A000BF115D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3FC9D901256248DF00BF115D /* Sort Project Alphabetically */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Sort Project Alphabetically"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${PROJECT_DIR}/BuildPhases/xcsort\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 3F28630D25A8E364004A579C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F28633725A8E44D004A579C /* HippoPaymentsConfirmationViewController.swift in Sources */, + 3F28633225A8E42C004A579C /* HippoPaymentsError.swift in Sources */, + 3F28632D25A8E416004A579C /* HippoPaymentsProcessor.swift in Sources */, + 3F28632825A8E3BE004A579C /* UIViewController+Presentation.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F2863AD25AA29A9004A579C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F2863C225AA29B9004A579C /* HippoAnalyticsClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8A92562389E00BF115D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F058C7325CAA53400315239 /* AlertViewModel.swift in Sources */, + 3FB6A66D25C0A9FA00505EA6 /* AppCoordinator.swift in Sources */, + 3F67592D25B8AF6B00564AA5 /* AppDelegate.swift in Sources */, + 3F40EEAD25BBB9AD0067FBA7 /* Collection+Safe.swift in Sources */, + 3F28636525A8EABA004A579C /* HippoPaymentsProcessor+PaymentProcessing.swift in Sources */, + 3F50071525A39A4900E25EED /* MenuFetcher.swift in Sources */, + 3FF67486256E800E00BD74AF /* MenuFetching.swift in Sources */, + 3FC9D8E825623AB500BF115D /* MenuGrouping.swift in Sources */, + 3FC9D8ED25623AD900BF115D /* MenuItem.swift in Sources */, + 3F40EECD25BBC36B0067FBA7 /* MenuItemDetailView.swift in Sources */, + 3FF54AB225BA39EF00C8F73B /* MenuItemDetailViewController.swift in Sources */, + 3FC30C5725C22C2B00C0471C /* MenuItemDetailViewModel.swift in Sources */, + 3FC30C7F25C23E4200C0471C /* MenuListTableViewDataSource.swift in Sources */, + 3FE48AB525C5E7BA00A46560 /* MenuListTableViewDelegate.swift in Sources */, + 3FF54A8A25BA0D8200C8F73B /* MenuListViewController.swift in Sources */, + 3F058CB325CAAA1000315239 /* MenuListViewModel.swift in Sources */, + 3F83FFE8259D66770017214F /* MenuRowViewModel.swift in Sources */, + 3F058C9125CAA8A700315239 /* MenuSection.swift in Sources */, + 3F56EE11257330FF00311F1A /* NetworkFetching.swift in Sources */, + 3F28636D25A8EAF1004A579C /* Order+HippoPayments.swift in Sources */, + 3F50065325A247BE00E25EED /* Order.swift in Sources */, + 3F50079925A4F46D00E25EED /* OrderButton.ViewModel.swift in Sources */, + 3F50066325A24B7000E25EED /* OrderController.swift in Sources */, + 3F40EE9625BB4C680067FBA7 /* OrderDetailViewController.swift in Sources */, + 3F50076425A4EFFA00E25EED /* OrderDetailViewModel.swift in Sources */, + 3FE010D825AB8B6D00D02C2E /* OrderStoring.swift in Sources */, + 3F28635425A8E886004A579C /* PaymentProcessing.swift in Sources */, + 3F67593B25B8B0B500564AA5 /* SceneDelegate.swift in Sources */, + 3F40EEA025BB55970067FBA7 /* UIButton+BigButtonStyle.swift in Sources */, + 3F50074325A4EB8B00E25EED /* UIColor+Custom.swift in Sources */, + 3F40EE5625BA4D4D0067FBA7 /* UIEdgeInsets+Convenience.swift in Sources */, + 3FC30C6B25C22FCA00C0471C /* UIFont+Utils.swift in Sources */, + 3F40EF2925BCA9EA0067FBA7 /* UITableViewFooterLabel.swift in Sources */, + 3FF54AA825BA0FBE00C8F73B /* UIView+AutoLayout.swift in Sources */, + 3F437A5525BE0625002BB2F8 /* UIViewControllerPresenting.swift in Sources */, + 3F56EE19257331BB00311F1A /* URLSession+NetworkFetching.swift in Sources */, + 3FE010E525AB8C1C00D02C2E /* UserDefaults+OrderStoring.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8BA2562389F00BF115D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F437A6C25BF561F002BB2F8 /* AppCoordinatorTests.swift in Sources */, + 3F56EE062571E96B00311F1A /* MenuFetcherTests.swift in Sources */, + 3F567DFB267BF0B9000A5361 /* MenuFetchingStub.swift in Sources */, + 3FC9D8DD2562397500BF115D /* MenuGroupingTests.swift in Sources */, + 3FF6741D256A708200BD74AF /* MenuItem+Fixture.swift in Sources */, + 3FF61B672571DE75003953C6 /* MenuItem+JSONFixture.swift in Sources */, + 3F56EDFB2571E16200311F1A /* MenuItemAlternateJSONTests.swift in Sources */, + 3F50068925A253A400E25EED /* MenuItemDetail.ViewModelTests.swift in Sources */, + 3F40EF4A25BCB7100067FBA7 /* MenuItemDetailViewControllerTests.swift in Sources */, + 3FC30C6125C22CC000C0471C /* MenuItemDetailViewTests.swift in Sources */, + 3FF61B47257096E0003953C6 /* MenuItemTests.swift in Sources */, + 3FF67475256CAA5900BD74AF /* MenuList.ViewModelTests.swift in Sources */, + 3FC30C7525C23A4000C0471C /* MenuListTableViewDataSourceTests.swift in Sources */, + 3FE48A9725C5E2FD00A46560 /* MenuListViewControllerTests.swift in Sources */, + 3FF6743B256A7C2200BD74AF /* MenuRow.ViewModelTests.swift in Sources */, + 3FF67425256A777000BD74AF /* MenuSection+Fixture.swift in Sources */, + 3FE48ABF25C5E7DF00A46560 /* MeunListTableViewDelegateTests.swift in Sources */, + 3F5F70A32681F4610096B7E5 /* NetworkFetchingStub.swift in Sources */, + 3F50077A25A4F1B700E25EED /* OrderButtonViewModelTests.swift in Sources */, + 3F50066B25A24C2900E25EED /* OrderControllerTests.swift in Sources */, + 3F50076C25A4F10100E25EED /* OrderDetail.ViewModelTests.swift in Sources */, + 3FE0114325AB937400D02C2E /* OrderStoringFake.swift in Sources */, + 3F50065B25A2491300E25EED /* OrderTests.swift in Sources */, + 3FE0120225ACCB1200D02C2E /* PaymentProcessingDummy.swift in Sources */, + 3F28637825A8ECE9004A579C /* PaymentProcessingSpy.swift in Sources */, + 3F2864EF25AA3FC8004A579C /* PaymentProcessingStub.swift in Sources */, + 3F40EF0D25BCA6F70067FBA7 /* SceneDelegateTests.swift in Sources */, + 3F567DFD267BF5A7000A5361 /* TestError.swift in Sources */, + 3FF61B5F2571DD29003953C6 /* XCTestCase+JSON.swift in Sources */, + 3F2864F925AA41D5004A579C /* XCTestCase+Timeouts.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FC9D8C5256238A000BF115D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FC9D8CE256238A000BF115D /* AlbertosUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 3F28631725A8E364004A579C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3F28631025A8E364004A579C /* HippoPayments */; + targetProxy = 3F28631625A8E364004A579C /* PBXContainerItemProxy */; + }; + 3F2863B725AA29A9004A579C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3F2863B025AA29A9004A579C /* HippoAnalytics */; + targetProxy = 3F2863B625AA29A9004A579C /* PBXContainerItemProxy */; + }; + 3FC9D8C0256238A000BF115D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3FC9D8AC2562389E00BF115D /* Albertos */; + targetProxy = 3FC9D8BF256238A000BF115D /* PBXContainerItemProxy */; + }; + 3FC9D8CB256238A000BF115D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3FC9D8AC2562389E00BF115D /* Albertos */; + targetProxy = 3FC9D8CA256238A000BF115D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 3F28631B25A8E364004A579C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = HippoPayments/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.tddinswift.HippoPayments; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 3F28631C25A8E364004A579C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = HippoPayments/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.tddinswift.HippoPayments; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 3F2863BA25AA29A9004A579C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = HippoAnalytics/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.tddinswift.HippoAnalytics; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 3F2863BB25AA29A9004A579C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = HippoAnalytics/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.tddinswift.HippoAnalytics; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 3FC9D8672562374800BF115D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + ONLY_ACTIVE_ARCH = YES; + }; + name = Debug; + }; + 3FC9D8682562374800BF115D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + }; + name = Release; + }; + 3FC9D8D0256238A000BF115D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_ASSET_PATHS = "\"Albertos/Preview Content\""; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = Albertos/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.Albertos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 3FC9D8D1256238A000BF115D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_ASSET_PATHS = "\"Albertos/Preview Content\""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = Albertos/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.Albertos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3FC9D8D2256238A000BF115D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = AlbertosTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.AlbertosTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Albertos.app/Albertos"; + }; + name = Debug; + }; + 3FC9D8D3256238A000BF115D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = AlbertosTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.AlbertosTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Albertos.app/Albertos"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3FC9D8D4256238A000BF115D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = AlbertosUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.AlbertosUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Albertos; + }; + name = Debug; + }; + 3FC9D8D5256238A000BF115D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = AlbertosUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.mokagio.AlbertosUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Albertos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3FC9D8662562374800BF115D /* Build configuration list for PBXProject "Albertos" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3FC9D8672562374800BF115D /* Debug */, + 3FC9D8682562374800BF115D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3FC9D8D6256238A000BF115D /* Build configuration list for PBXNativeTarget "Albertos" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3FC9D8D0256238A000BF115D /* Debug */, + 3FC9D8D1256238A000BF115D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3FC9D8D7256238A000BF115D /* Build configuration list for PBXNativeTarget "AlbertosTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3FC9D8D2256238A000BF115D /* Debug */, + 3FC9D8D3256238A000BF115D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3FC9D8D8256238A000BF115D /* Build configuration list for PBXNativeTarget "AlbertosUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3FC9D8D4256238A000BF115D /* Debug */, + 3FC9D8D5256238A000BF115D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3F2863BC25AA29A9004A579C /* Build configuration list for PBXNativeTarget "HippoAnalytics" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3F2863BA25AA29A9004A579C /* Debug */, + 3F2863BB25AA29A9004A579C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3F28631D25A8E364004A579C /* Build configuration list for PBXNativeTarget "HippoPayments" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3F28631B25A8E364004A579C /* Debug */, + 3F28631C25A8E364004A579C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 3F40EF1625BCA7F90067FBA7 /* XCRemoteSwiftPackageReference "Nimble" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "http://github.com/Quick/Nimble.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 3F40EF1E25BCA8150067FBA7 /* Nimble */ = { + isa = XCSwiftPackageProductDependency; + package = 3F40EF1625BCA7F90067FBA7 /* XCRemoteSwiftPackageReference "Nimble" */; + productName = Nimble; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 3FC9D8632562374800BF115D /* Project object */; +} diff --git a/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..7d641f7 --- /dev/null +++ b/19-appendix-c-uikit/Albertos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,34 @@ +{ + "object": { + "pins": [ + { + "package": "CwlCatchException", + "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", + "state": { + "branch": null, + "revision": "682841464136f8c66e04afe5dbd01ab51a3a56f2", + "version": "2.1.0" + } + }, + { + "package": "CwlPreconditionTesting", + "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state": { + "branch": null, + "revision": "02b7a39a99c4da27abe03cab2053a9034379639f", + "version": "2.0.0" + } + }, + { + "package": "Nimble", + "repositoryURL": "http://github.com/Quick/Nimble.git", + "state": { + "branch": null, + "revision": "af1730dde4e6c0d45bf01b99f8a41713ce536790", + "version": "9.2.0" + } + } + ] + }, + "version": 1 +} diff --git a/19-appendix-c-uikit/Albertos.xcodeproj/xcshareddata/xcschemes/Albertos.xcscheme b/19-appendix-c-uikit/Albertos.xcodeproj/xcshareddata/xcschemes/Albertos.xcscheme new file mode 100644 index 0000000..a5ed3b5 --- /dev/null +++ b/19-appendix-c-uikit/Albertos.xcodeproj/xcshareddata/xcschemes/Albertos.xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/19-appendix-c-uikit/Albertos/AlertViewModel.swift b/19-appendix-c-uikit/Albertos/AlertViewModel.swift new file mode 100644 index 0000000..2b1dcdc --- /dev/null +++ b/19-appendix-c-uikit/Albertos/AlertViewModel.swift @@ -0,0 +1,7 @@ +struct AlertViewModel { + + let title: String + let message: String + let buttonText: String + let buttonAction: (() -> Void)? +} diff --git a/19-appendix-c-uikit/Albertos/AppCoordinator.swift b/19-appendix-c-uikit/Albertos/AppCoordinator.swift new file mode 100644 index 0000000..4187946 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/AppCoordinator.swift @@ -0,0 +1,84 @@ +import Combine +import UIKit + +class AppCoordinator: MenuListViewControllerNavigationDelegate, OrderDetailViewControllerDelegate { + + let navigationController: UINavigationController + private let orderController: OrderController + private let paymentProcessing: PaymentProcessing + + private lazy var menuListViewController: MenuListViewController = { + let viewController = MenuListViewController(menuFetching: MenuFetcher()) + viewController.navigationDelegate = self + return viewController + }() + + private let orderButton: UIButton + + private lazy var orderButtonViewModel = OrderButtonViewModel(orderController: orderController) + + private var cancellables = Set() + + init( + orderController: OrderController, + paymentProcessing: PaymentProcessing, + navigationController: UINavigationController = UINavigationController() + ) { + self.navigationController = navigationController + self.orderController = orderController + self.paymentProcessing = paymentProcessing + + orderButton = BigButton() // a `UIButton` subclass that configures the button style + + orderButtonViewModel.$text + .sink { [weak self] text in + guard let button = self?.orderButton else { return } + button.setTitle(text, for: .normal) + } + .store(in: &cancellables) + + orderButton.addAction( + UIAction(handler: { [weak self] _ in self?.presentOrderDetail()}), + for: .primaryActionTriggered + ) + } + + func loadFirstScreen() { + navigationController.viewControllers = [menuListViewController] + + navigationController.view.addSubview(orderButton) + + orderButton.pin(.centerX, to: navigationController.view) + orderButton.alignSafeAreaBottomAnchor(to: navigationController.view) + } + + func orderDetailViewControllerCompletedPaymentFlow(_ viewController: OrderDetailViewController) { + navigationController.dismiss(animated: true, completion: .none) + } + + func menuListViewController( + _ viewController: MenuListViewController, + didSelectItem item: MenuItem + ) { + navigationController.pushViewController( + MenuItemDetailViewController( + viewModel: .init(item: item, orderController: orderController) + ), + animated: true + ) + } + + func presentOrderDetail() { + let orderDetailViewController = OrderDetailViewController( + orderController: orderController, + paymentProcessor: paymentProcessing + ) + orderDetailViewController.delegate = self + + navigationController.present( + UINavigationController(rootViewController: orderDetailViewController), + animated: true, + completion: .none + ) + } +} diff --git a/19-appendix-c-uikit/Albertos/AppDelegate.swift b/19-appendix-c-uikit/Albertos/AppDelegate.swift new file mode 100644 index 0000000..a14f12a --- /dev/null +++ b/19-appendix-c-uikit/Albertos/AppDelegate.swift @@ -0,0 +1,15 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: - UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} diff --git a/19-appendix-c-uikit/Albertos/Assets.xcassets/AccentColor.colorset/Contents.json b/19-appendix-c-uikit/Albertos/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/19-appendix-c-uikit/Albertos/Assets.xcassets/AppIcon.appiconset/Contents.json b/19-appendix-c-uikit/Albertos/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9221b9b --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/19-appendix-c-uikit/Albertos/Assets.xcassets/Contents.json b/19-appendix-c-uikit/Albertos/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/19-appendix-c-uikit/Albertos/Collection+Safe.swift b/19-appendix-c-uikit/Albertos/Collection+Safe.swift new file mode 100644 index 0000000..0d7daad --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Collection+Safe.swift @@ -0,0 +1,7 @@ +extension Collection { + + /// Returns the element at the specified index if it is within range, otherwise nil. + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/19-appendix-c-uikit/Albertos/HippoPaymentsProcessor+PaymentProcessing.swift b/19-appendix-c-uikit/Albertos/HippoPaymentsProcessor+PaymentProcessing.swift new file mode 100644 index 0000000..f8c6eb6 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/HippoPaymentsProcessor+PaymentProcessing.swift @@ -0,0 +1,16 @@ +import Combine +import HippoPayments + +extension HippoPaymentsProcessor: PaymentProcessing { + + func process(order: Order) -> AnyPublisher { + return Future { promise in + self.processPayment( + payload: order.hippoPaymentsPayload, + onSuccess: { promise(.success(())) }, + onFailure: { promise(.failure($0)) } + ) + } + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/Albertos/Info.plist b/19-appendix-c-uikit/Albertos/Info.plist new file mode 100644 index 0000000..0b6d132 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Info.plist @@ -0,0 +1,62 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/19-appendix-c-uikit/Albertos/MenuFetcher.swift b/19-appendix-c-uikit/Albertos/MenuFetcher.swift new file mode 100644 index 0000000..4f9cc89 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuFetcher.swift @@ -0,0 +1,17 @@ +import Combine +import Foundation + +class MenuFetcher: MenuFetching { + + let networkFetching: NetworkFetching + + init(networkFetching: NetworkFetching = URLSession.shared) { + self.networkFetching = networkFetching + } + + func fetchMenu() -> AnyPublisher<[MenuItem], Error> { + return networkFetching.load(URLRequest(url: URL(string: "https://s3.amazonaws.com/mokacoding/menu_response.json")!)) + .decode(type: [MenuItem].self, decoder: JSONDecoder()) + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuFetching.swift b/19-appendix-c-uikit/Albertos/MenuFetching.swift new file mode 100644 index 0000000..43d2f1e --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuFetching.swift @@ -0,0 +1,6 @@ +import Combine + +protocol MenuFetching { + + func fetchMenu() -> AnyPublisher<[MenuItem], Error> +} diff --git a/19-appendix-c-uikit/Albertos/MenuGrouping.swift b/19-appendix-c-uikit/Albertos/MenuGrouping.swift new file mode 100644 index 0000000..f665496 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuGrouping.swift @@ -0,0 +1,7 @@ +func groupMenuByCategory(_ menu: [MenuItem]) -> [MenuSection] { + guard menu.isEmpty == false else { return [] } + + return Dictionary(grouping: menu, by: { $0.category }) + .map { key, value in MenuSection(category: key, items: value) } + .sorted { $0.category > $1.category } +} diff --git a/19-appendix-c-uikit/Albertos/MenuItem.swift b/19-appendix-c-uikit/Albertos/MenuItem.swift new file mode 100644 index 0000000..85a2b5b --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuItem.swift @@ -0,0 +1,11 @@ +struct MenuItem { + + let category: String + let name: String + let spicy: Bool + let price: Double +} + +extension MenuItem: Equatable {} + +extension MenuItem: Codable {} diff --git a/19-appendix-c-uikit/Albertos/MenuItemDetailView.swift b/19-appendix-c-uikit/Albertos/MenuItemDetailView.swift new file mode 100644 index 0000000..b2e41cd --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuItemDetailView.swift @@ -0,0 +1,40 @@ +import UIKit + +class MenuItemDetailView: UIStackView { + + let nameLabel = UILabel() + let priceLabel = UILabel() + lazy private(set) var spicyLabel = UILabel() + let addOrRemoveFromOrderButton: UIButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + + axis = .vertical + alignment = .leading + distribution = .fillProportionally + spacing = 8 + } + + @available (*, unavailable, message: "This view has no `.xib` backing it. Use `init(frame:)` instead.") + required init(coder: NSCoder) { + fatalError("This view has no `.xib` backing it. Use `init(frame:)` instead.") + } + + func configureContent(with viewModel: MenuItemDetailViewModel) { + nameLabel.text = viewModel.name + addArrangedSubview(nameLabel) + + if let spicy = viewModel.spicy { + spicyLabel.text = spicy + spicyLabel.font = spicyLabel.font.adding(.traitItalic) + addArrangedSubview(spicyLabel) + } + + priceLabel.text = viewModel.price + addArrangedSubview(priceLabel) + + addOrRemoveFromOrderButton.setTitle(viewModel.addOrRemoveFromOrderButtonText, for: .normal) + addArrangedSubview(addOrRemoveFromOrderButton) + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuItemDetailViewController.swift b/19-appendix-c-uikit/Albertos/MenuItemDetailViewController.swift new file mode 100644 index 0000000..5b02f5d --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuItemDetailViewController.swift @@ -0,0 +1,64 @@ +import UIKit + +class MenuItemDetailViewController: UIViewController { + + let containerView = MenuItemDetailView() + + private let viewModel: MenuItemDetailViewModel + + init(viewModel: MenuItemDetailViewModel) { + self.viewModel = viewModel + super.init(nibName: .none, bundle: .none) + } + + // MARK: - Make other inits unavailable + + @available(*, unavailable, message: "Use `init(item:, orderController:)` instead") + init() { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + @available(*, unavailable, message: "Use `init` instead") + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + @available(*, unavailable, message: "Use `init` instead") + required init?(coder: NSCoder) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + // MARK: - View life-cycle + + override func viewDidLoad() { + super.viewDidLoad() + + configureViewLayout() + + containerView.configureContent(with: viewModel) + + containerView.addOrRemoveFromOrderButton.addAction( + UIAction( + handler: { [weak self] _ in + guard let self = self else { return } + self.viewModel.addOrRemoveFromOrder() + self.containerView.configureContent(with: self.viewModel) + } + ), + for: .primaryActionTriggered + ) + } + + // MARK: - + + private func configureViewLayout() { + view.backgroundColor = .systemBackground + + // Use this to show the navigation bar +// navigationItem.largeTitleDisplayMode = .never + + view.addSubview(containerView) + containerView.pin([.topMargin, .leftMargin, .rightMargin], to: view) + containerView.setContentHuggingPriority(.defaultHigh, for: .vertical) + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuItemDetailViewModel.swift b/19-appendix-c-uikit/Albertos/MenuItemDetailViewModel.swift new file mode 100644 index 0000000..6002bc3 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuItemDetailViewModel.swift @@ -0,0 +1,49 @@ +class MenuItemDetailViewModel { + + let name: String + let spicy: String? + let price: String + private(set) var addOrRemoveFromOrderButtonText: String + + private let item: MenuItem + private let orderController: OrderController + + init(item: MenuItem, orderController: OrderController) { + self.item = item + self.orderController = orderController + + name = item.name + spicy = item.spicy ? "Spicy" : .none + price = "$\(String(format: "%.2f", item.price))" + addOrRemoveFromOrderButtonText = getAddOrRemoveFromOrderButtonText( + item: item, + order: orderController.order + ) + + if (orderController.order.items.contains { $0 == item }) { + self.addOrRemoveFromOrderButtonText = "Remove from order" + } else { + self.addOrRemoveFromOrderButtonText = "Add to order" + } + } + + func addOrRemoveFromOrder() { + if (orderController.order.items.contains { $0 == item }) { + orderController.removeFromOrder(item: item) + } else { + orderController.addToOrder(item: item) + } + addOrRemoveFromOrderButtonText = getAddOrRemoveFromOrderButtonText( + item: item, + order: orderController.order + ) + } +} + +private func getAddOrRemoveFromOrderButtonText(item: MenuItem, order: Order) -> String { + if (order.items.contains { $0 == item }) { + return "Remove from order" + } else { + return "Add to order" + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuListTableViewDataSource.swift b/19-appendix-c-uikit/Albertos/MenuListTableViewDataSource.swift new file mode 100644 index 0000000..acd2d4c --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuListTableViewDataSource.swift @@ -0,0 +1,56 @@ +import UIKit + +class MenuListTableViewDataSource: NSObject, UITableViewDataSource { + + private var sections: Result<[MenuSection], Error> + private let cellIdentifier = "cell" + + init(initialSections sections: Result<[MenuSection], Error> = .success([])) { + self.sections = sections + super.init() + } + + func setAsDataSourceOf(_ tableView: UITableView) { + tableView.dataSource = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier) + } + + func reload(_ tableView: UITableView, with sections: Result<[MenuSection], Error>) { + self.sections = sections + tableView.reloadData() + } + + func numberOfSections(in tableView: UITableView) -> Int { + guard case .success(let sections) = sections else { return 1 } + return sections.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard case .success(let sections) = sections else { return .none } + return sections[safe: section]?.category.uppercased() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard case .success(let sections) = sections else { return 1 } + return sections[safe: section]?.items.count ?? 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) + + switch sections { + case .failure(let error): + cell.textLabel?.text = "An error occurred" + cell.detailTextLabel?.text = "\(error.localizedDescription)" + case .success(let sections): + guard let item = sections[safe: indexPath.section]?.items[safe: indexPath.row] else { + return cell + } + let viewModel = MenuRowViewModel(item: item) + cell.textLabel?.text = viewModel.text + cell.accessoryType = .disclosureIndicator + } + + return cell + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuListTableViewDelegate.swift b/19-appendix-c-uikit/Albertos/MenuListTableViewDelegate.swift new file mode 100644 index 0000000..5ad02d6 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuListTableViewDelegate.swift @@ -0,0 +1,22 @@ +import UIKit + +class MenuListTableViewDelegate: NSObject, UITableViewDelegate { + + var sections: Result<[MenuSection], Error> = .success([]) + + let onRowSelected: (MenuItem) -> () + + init(onRowSelected: @escaping (MenuItem) -> ()) { + self.onRowSelected = onRowSelected + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard case .success(let sections) = sections else { return } + + let item = sections[indexPath.section].items[indexPath.row] + + onRowSelected(item) + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuListViewController.swift b/19-appendix-c-uikit/Albertos/MenuListViewController.swift new file mode 100644 index 0000000..4bd9c30 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuListViewController.swift @@ -0,0 +1,73 @@ +import Combine +import UIKit + +protocol MenuListViewControllerNavigationDelegate: AnyObject { + + func menuListViewController( + _ viewController: MenuListViewController, + didSelectItem item: MenuItem + ) +} + +class MenuListViewController: UIViewController { + + let tableView = UITableView() + + weak var navigationDelegate: MenuListViewControllerNavigationDelegate? + + let viewModel: MenuListViewModel + + lazy private var tableViewDataSource = MenuListTableViewDataSource() + lazy private var tableViewDelegate = MenuListTableViewDelegate( + onRowSelected: { [weak self] in + guard let self = self else { return } + self.navigationDelegate?.menuListViewController(self, didSelectItem: $0) + } + ) + + private var cancellables = Set() + + init(menuFetching: MenuFetching) { + self.viewModel = MenuListViewModel(menuFetching: menuFetching) + super.init(nibName: .none, bundle: .none) + } + + @available(*, unavailable, message: "Use `init` instead") + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + @available(*, unavailable, message: "Use `init` instead") + required init?(coder: NSCoder) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureViewLayout() + + tableViewDataSource.setAsDataSourceOf(tableView) + tableView.delegate = tableViewDelegate + + viewModel.$sections + .receive(on: RunLoop.main) + .sink { [weak self] sections in + guard let self = self else { return } + + self.tableViewDataSource.reload(self.tableView, with: sections) + self.tableViewDelegate.sections = sections + } + .store(in: &cancellables) + } + + private func configureViewLayout() { + title = "Albertos 🇮🇹" + navigationController?.navigationBar.prefersLargeTitles = true + + view.addSubview(tableView) + tableView.fill(view) + // Don't show empty cells if there are less items that what would fill the screen + tableView.tableFooterView = UIView() + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuListViewModel.swift b/19-appendix-c-uikit/Albertos/MenuListViewModel.swift new file mode 100644 index 0000000..bdf5dfc --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuListViewModel.swift @@ -0,0 +1,30 @@ +import Combine + +class MenuListViewModel: ObservableObject { + + @Published private(set) var sections: Result<[MenuSection], Error> = .success([]) + + private let menuFetching: MenuFetching + private var cancellables = Set() + + init( + menuFetching: MenuFetching, + menuGrouping: @escaping ([MenuItem]) -> [MenuSection] = groupMenuByCategory + ) { + self.menuFetching = menuFetching + + menuFetching + .fetchMenu() + .map(menuGrouping) + .sink( + receiveCompletion: { [weak self] completion in + guard case .failure(let error) = completion else { return } + self?.sections = .failure(error) + }, + receiveValue: { [weak self] value in + self?.sections = .success(value) + } + ) + .store(in: &cancellables) + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuRowViewModel.swift b/19-appendix-c-uikit/Albertos/MenuRowViewModel.swift new file mode 100644 index 0000000..b6eb033 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuRowViewModel.swift @@ -0,0 +1,8 @@ +struct MenuRowViewModel { + + let text: String + + init(item: MenuItem) { + text = item.spicy ? "\(item.name) 🔥" : item.name + } +} diff --git a/19-appendix-c-uikit/Albertos/MenuSection.swift b/19-appendix-c-uikit/Albertos/MenuSection.swift new file mode 100644 index 0000000..a78d04b --- /dev/null +++ b/19-appendix-c-uikit/Albertos/MenuSection.swift @@ -0,0 +1,7 @@ +struct MenuSection { + + let category: String + let items: [MenuItem] +} + +extension MenuSection: Equatable {} diff --git a/19-appendix-c-uikit/Albertos/NetworkFetching.swift b/19-appendix-c-uikit/Albertos/NetworkFetching.swift new file mode 100644 index 0000000..2d4f186 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/NetworkFetching.swift @@ -0,0 +1,7 @@ +import Combine +import Foundation + +protocol NetworkFetching { + + func load(_ request: URLRequest) -> AnyPublisher +} diff --git a/19-appendix-c-uikit/Albertos/Order+HippoPayments.swift b/19-appendix-c-uikit/Albertos/Order+HippoPayments.swift new file mode 100644 index 0000000..78fd5cf --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Order+HippoPayments.swift @@ -0,0 +1,4 @@ +extension Order { + + var hippoPaymentsPayload: [String: Any] { ["items": items.map { $0.name }] } +} diff --git a/19-appendix-c-uikit/Albertos/Order.swift b/19-appendix-c-uikit/Albertos/Order.swift new file mode 100644 index 0000000..8f14348 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Order.swift @@ -0,0 +1,8 @@ +struct Order { + + let items: [MenuItem] + + var total: Double { items.reduce(0) { $0 + $1.price } } +} + +extension Order: Codable, Equatable {} diff --git a/19-appendix-c-uikit/Albertos/OrderButton.ViewModel.swift b/19-appendix-c-uikit/Albertos/OrderButton.ViewModel.swift new file mode 100644 index 0000000..82ff5d4 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/OrderButton.ViewModel.swift @@ -0,0 +1,16 @@ +import Combine + +class OrderButtonViewModel: ObservableObject { + + @Published private(set) var text = "Your Order" + + private(set) var cancellables = Set() + + init(orderController: OrderController) { + orderController.$order + .sink { order in + self.text = order.items.isEmpty ? "Your Order" : "Your Order $\(String(format: "%.2f", order.total))" + } + .store(in: &cancellables) + } +} diff --git a/19-appendix-c-uikit/Albertos/OrderController.swift b/19-appendix-c-uikit/Albertos/OrderController.swift new file mode 100644 index 0000000..630d109 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/OrderController.swift @@ -0,0 +1,43 @@ +import Combine +import Foundation + +class OrderController: ObservableObject { + + @Published private(set) var order: Order + + private let orderStoring: OrderStoring + + init(orderStoring: OrderStoring = UserDefaults.standard) { + self.orderStoring = orderStoring + order = orderStoring.getOrder() + } + + func isItemInOrder(_ item: MenuItem) -> Bool { + return order.items.contains { $0 == item } + } + + func addToOrder(item: MenuItem) { + updateOrder(with: Order(items: order.items + [item])) + } + + func removeFromOrder(item: MenuItem) { + let items = order.items + guard let indexToRemove = items.firstIndex(where: { $0.name == item.name }) else { return } + + let newItems = items.enumerated().compactMap { (index, element) -> MenuItem? in + guard index == indexToRemove else { return element } + return .none + } + + updateOrder(with: Order(items: newItems)) + } + + func resetOrder() { + updateOrder(with: Order(items: [])) + } + + private func updateOrder(with newOrder: Order) { + orderStoring.updateOrder(newOrder) + order = newOrder + } +} diff --git a/19-appendix-c-uikit/Albertos/OrderDetailViewController.swift b/19-appendix-c-uikit/Albertos/OrderDetailViewController.swift new file mode 100644 index 0000000..622f036 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/OrderDetailViewController.swift @@ -0,0 +1,153 @@ +import Combine +import UIKit + +protocol OrderDetailViewControllerDelegate: AnyObject { + + func orderDetailViewControllerCompletedPaymentFlow(_ viewController: OrderDetailViewController) +} + +class OrderDetailViewController: UIViewController { + + lazy private var tableView = UITableView() + lazy private var emptyMenuLabel = UILabel() + lazy private var checkoutButton = BigButton() + lazy private var totalPriceLabel = UITableViewFooterLabel() + + private let viewModel: OrderDetailViewModel + + weak var delegate: OrderDetailViewControllerDelegate? + + private var cancellables = Set() + + init(orderController: OrderController, paymentProcessor: PaymentProcessing) { + self.viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: paymentProcessor, + // Leaving this empty and relying on a navigation delegate, which is more UIKit-style + onAlertDismiss: {} + ) + super.init(nibName: .none, bundle: .none) + } + + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + @available(*, unavailable, message: "Use `init` instead") + required init?(coder: NSCoder) { + fatalError("This view controller has no `.xib` backing it. Use `init` instead.") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureViewLayout() + + checkoutButton.addAction( + UIAction(handler: { [weak self] _ in self?.viewModel.checkout() }), + for: .primaryActionTriggered + ) + + viewModel.$alertToShow + .compactMap { $0 } + .map { + return AlertViewModel( + title: $0.title, + message: $0.message, + buttonText: $0.buttonText, + buttonAction: $0.buttonAction + ) + } + .sink { [weak self] alertViewModel in + self?.showAlert(with: alertViewModel) + } + .store(in: &cancellables) + } + + private func configureViewLayout() { + title = "Your Order" + navigationController?.navigationBar.prefersLargeTitles = true + + view.backgroundColor = .systemBackground + + if viewModel.menuListItems.isEmpty { + view.addSubview(emptyMenuLabel) + emptyMenuLabel.pin([.leadingMargin, .trailingMargin], to: view, padding: 8) + emptyMenuLabel.alignSafeAreaTopAnchor(to: view) + emptyMenuLabel.text = viewModel.emptyMenuFallbackText + } else { + view.addSubview(tableView) + tableView.fill(view) + tableView.tableFooterView = totalPriceLabel + // TODO: It would be better to use full Auto Layout with dynamic label height instead + // of setting the frame manually. + totalPriceLabel.frame = CGRect(origin: .zero, size: CGSize(width: view.frame.width, height: 40)) + + view.addSubview(checkoutButton) + checkoutButton.pin(.centerX, to: view) + checkoutButton.alignSafeAreaBottomAnchor(to: view) + + totalPriceLabel.textAlignment = .center + totalPriceLabel.text = viewModel.totalText + if let fontDescriptor = totalPriceLabel.font.fontDescriptor.withSymbolicTraits(.traitItalic) { + // size = 0 means "keep the current size" + totalPriceLabel.font = UIFont(descriptor: fontDescriptor, size: 0) + } + + tableView.dataSource = self + tableView.delegate = self + + checkoutButton.setTitle("Checkout", for: .normal) + } + } + + private func showAlert(with viewModel: AlertViewModel) { + let alert = UIAlertController( + title: viewModel.title, + message: viewModel.message, + preferredStyle: .alert + ) + + alert.addAction( + UIAlertAction( + title: viewModel.buttonText, + style: .default, + handler: { [weak self] _ in + viewModel.buttonAction?() + guard let self = self else { return } + self.delegate?.orderDetailViewControllerCompletedPaymentFlow(self) + } + ) + ) + + present(alert, animated: true, completion: .none) + } +} + +extension OrderDetailViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.menuListItems.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: .none) + + let item = viewModel.menuListItems[indexPath.row] + + cell.textLabel?.text = item.name + + return cell + } +} + +extension OrderDetailViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + return .none + } +} diff --git a/19-appendix-c-uikit/Albertos/OrderDetailViewModel.swift b/19-appendix-c-uikit/Albertos/OrderDetailViewModel.swift new file mode 100644 index 0000000..084d1ed --- /dev/null +++ b/19-appendix-c-uikit/Albertos/OrderDetailViewModel.swift @@ -0,0 +1,72 @@ +import Combine +import HippoPayments + +class OrderDetailViewModel: ObservableObject { + + let headerText = "Your Order" + let menuListItems: [MenuItem] + let emptyMenuFallbackText = "Add dishes to the order to see them here" + let totalText: String? + + let shouldShowCheckoutButton: Bool + let checkoutButtonText = "Checkout" + + private let orderController: OrderController + private let paymentProcessor: PaymentProcessing + + private let onAlertDismiss: () -> Void + + @Published var alertToShow: AlertViewModel? + + private var cancellables = Set() + + init( + orderController: OrderController, + paymentProcessor: PaymentProcessing, + onAlertDismiss: @escaping () -> Void + ) { + self.orderController = orderController + self.paymentProcessor = paymentProcessor + self.onAlertDismiss = onAlertDismiss + + if orderController.order.items.isEmpty { + totalText = .none + shouldShowCheckoutButton = false + } else { + totalText = "Total: $\(String(format: "%.2f", orderController.order.total))" + shouldShowCheckoutButton = true + } + + menuListItems = orderController.order.items + } + + func checkout() { + paymentProcessor.process(order: orderController.order) + .sink( + receiveCompletion: { [weak self] completion in + guard case .failure = completion else { return } + + self?.alertToShow = AlertViewModel( + title: "", + message: "There's been an error with your order. Please contact a waiter.", + buttonText: "Ok", + buttonAction: self?.onAlertDismiss + ) + }, + receiveValue: { [weak self] _ in + self?.alertToShow = AlertViewModel( + title: "", + message: "The payment was successful. Your food will be with you shortly.", + buttonText: "Ok", + buttonAction: { [weak self] in + guard let self = self else { return } + + self.orderController.resetOrder() + self.onAlertDismiss() + } + ) + } + ) + .store(in: &cancellables) + } +} diff --git a/19-appendix-c-uikit/Albertos/OrderStoring.swift b/19-appendix-c-uikit/Albertos/OrderStoring.swift new file mode 100644 index 0000000..b798268 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/OrderStoring.swift @@ -0,0 +1,6 @@ +protocol OrderStoring { + + func getOrder() -> Order + + func updateOrder(_ order: Order) +} diff --git a/19-appendix-c-uikit/Albertos/PaymentProcessing.swift b/19-appendix-c-uikit/Albertos/PaymentProcessing.swift new file mode 100644 index 0000000..23b8b25 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/PaymentProcessing.swift @@ -0,0 +1,6 @@ +import Combine + +protocol PaymentProcessing { + + func process(order: Order) -> AnyPublisher +} diff --git a/19-appendix-c-uikit/Albertos/Preview Content/Preview Assets.xcassets/Contents.json b/19-appendix-c-uikit/Albertos/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/19-appendix-c-uikit/Albertos/SceneDelegate.swift b/19-appendix-c-uikit/Albertos/SceneDelegate.swift new file mode 100644 index 0000000..ef8b7ab --- /dev/null +++ b/19-appendix-c-uikit/Albertos/SceneDelegate.swift @@ -0,0 +1,33 @@ +import Combine +import HippoPayments +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + let orderController = OrderController() + let paymentProcessing = HippoPaymentsProcessor(apiKey: "ABC") + + let orderButton = BigButton() + lazy var orderButtonViewModel = OrderButtonViewModel(orderController: orderController) + private var cancellables = Set() + + lazy var coordinator = AppCoordinator( + orderController: orderController, + paymentProcessing: paymentProcessing + ) + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard NSClassFromString("XCTestCase") == nil else { return } + + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = coordinator.navigationController + self.window = window + window.makeKeyAndVisible() + + coordinator.loadFirstScreen() + } +} diff --git a/19-appendix-c-uikit/Albertos/UIButton+BigButtonStyle.swift b/19-appendix-c-uikit/Albertos/UIButton+BigButtonStyle.swift new file mode 100644 index 0000000..7329ffc --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UIButton+BigButtonStyle.swift @@ -0,0 +1,49 @@ +import UIKit + +extension UIButton { + + func applyBigButtonStyle() { + backgroundColor = .crimson + + let padding = CGFloat(12.0) + set(.height, to: intrinsicContentSize.height + padding) + set(.width, to: intrinsicContentSize.width + padding) + + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + layer.cornerRadius = 10 + } +} + +class BigButton: UIButton { + + private let padding: CGFloat + + override init(frame: CGRect) { + self.padding = 6 * 2 + + super.init(frame: .zero) + + backgroundColor = .crimson + + let padding = CGFloat(12.0) + set(.height, to: intrinsicContentSize.height + padding) + set(.width, to: intrinsicContentSize.width + padding) + + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + layer.cornerRadius = 10 + } + + required init?(coder: NSCoder) { + fatalError("This view has no `.xib` backing it. Use `init` instead.") + } + + override func setTitle(_ title: String?, for state: UIControl.State) { + super.setTitle(title, for: state) + set(.height, to: intrinsicContentSize.height + padding) + set(.width, to: intrinsicContentSize.width + padding) + } +} diff --git a/19-appendix-c-uikit/Albertos/UIColor+Custom.swift b/19-appendix-c-uikit/Albertos/UIColor+Custom.swift new file mode 100644 index 0000000..081acea --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UIColor+Custom.swift @@ -0,0 +1,19 @@ +import UIKit + +// These are a few shades of red from the CSS colors list. +// +// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value +extension UIColor { + + static var crimson: UIColor { + UIColor(red: 220 / 255.0, green: 20 / 255.0, blue: 20 / 255.0, alpha: 1.0) + } + + static var tomato: UIColor { + UIColor(red: 255 / 255.0, green: 99 / 255.0, blue: 71 / 255.0, alpha: 1.0) + } + + static var orangered: UIColor { + UIColor(red: 255 / 255.0, green: 69 / 255.0, blue: 0 / 255.0, alpha: 1.0) + } +} diff --git a/19-appendix-c-uikit/Albertos/UIFont+Utils.swift b/19-appendix-c-uikit/Albertos/UIFont+Utils.swift new file mode 100644 index 0000000..76473cc --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UIFont+Utils.swift @@ -0,0 +1,13 @@ +import UIKit + +extension UIFont { + + func adding(_ trait: UIFontDescriptor.SymbolicTraits) -> UIFont { + guard let fontDescriptor = fontDescriptor.withSymbolicTraits(trait) else { + return self + } + + // size = 0 means "keep the current size" + return UIFont(descriptor: fontDescriptor, size: 0) + } +} diff --git a/19-appendix-c-uikit/Albertos/UITableViewFooterLabel.swift b/19-appendix-c-uikit/Albertos/UITableViewFooterLabel.swift new file mode 100644 index 0000000..7789abe --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UITableViewFooterLabel.swift @@ -0,0 +1,34 @@ +import UIKit + +class UITableViewFooterLabel: UIView { + + var text: String? { + get { label.text } + set { label.text = newValue } + } + + var textAlignment: NSTextAlignment { + get { label.textAlignment } + set { label.textAlignment = newValue } + } + + var font: UIFont { + get { label.font } + set { label.font = newValue } + } + + private let label = UILabel(frame: .zero) + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(label) + + label.numberOfLines = 0 + label.fill(self) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("This view has no `.xib` backing it. Use `init` instead.") + } +} diff --git a/19-appendix-c-uikit/Albertos/UIView+AutoLayout.swift b/19-appendix-c-uikit/Albertos/UIView+AutoLayout.swift new file mode 100644 index 0000000..ba71a77 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UIView+AutoLayout.swift @@ -0,0 +1,92 @@ +import UIKit + +extension UIView { + + func set( + _ attribute: NSLayoutConstraint.Attribute, + relatedBy relation: NSLayoutConstraint.Relation = .equal, + to constant: Double + ) { + set(attribute, relatedBy: relation, to: CGFloat(constant)) + } + + func set( + _ attribute: NSLayoutConstraint.Attribute, + relatedBy relation: NSLayoutConstraint.Relation = .equal, + to constant: CGFloat + ) { + self.translatesAutoresizingMaskIntoConstraints = false + + let matchesConstraint: (NSLayoutConstraint) -> Bool = { + $0.relation == relation + && $0.firstAttribute == attribute + && ($0.firstItem as? UIView) == self + } + constraints.filter(matchesConstraint).forEach { removeConstraint($0) } + + addConstraint( + NSLayoutConstraint( + item: self, + attribute: attribute, + relatedBy: .equal, + toItem: .none, + attribute: .notAnAttribute, + multiplier: 1, + constant: constant + ) + ) + } + + func fill(_ superview: UIView) { + pin([.topMargin, .leftMargin, .bottomMargin, .rightMargin], to: superview) + } + + func pin( + _ attribute: NSLayoutConstraint.Attribute, + to view: UIView, + padding: Double = 0 + ) { + pin([attribute], to: view, padding: padding) + } + + func pin( + _ attributes: [NSLayoutConstraint.Attribute], + to view: UIView, + padding: Double = 0 + ) { + self.translatesAutoresizingMaskIntoConstraints = false + + view.addConstraints( + attributes.map { + NSLayoutConstraint( + item: self, + attribute: $0, + relatedBy: .equal, + toItem: view, + attribute: $0, + multiplier: 1, + constant: CGFloat(padding) + ) + } + ) + } + + func alignSafeAreaTopAnchor(to view: UIView) { + align(topAnchor, with: view.safeAreaLayoutGuide.topAnchor) + } + + func alignSafeAreaBottomAnchor(to view: UIView) { + align(bottomAnchor, with: view.safeAreaLayoutGuide.bottomAnchor) + } + + private func align(_ anchor: NSLayoutYAxisAnchor, with anchorToAlightWith: NSLayoutYAxisAnchor) { + NSLayoutConstraint.activate( + [ + anchorToAlightWith.constraint( + equalToSystemSpacingBelow: anchor, + multiplier: 0 + ) + ] + ) + } +} diff --git a/19-appendix-c-uikit/Albertos/UIViewControllerPresenting.swift b/19-appendix-c-uikit/Albertos/UIViewControllerPresenting.swift new file mode 100644 index 0000000..4be88f0 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UIViewControllerPresenting.swift @@ -0,0 +1,14 @@ +import UIKit + +/// Describes the ability of presenting and dismissing a `UIViewController` modally on top of the +/// current screen. +/// +/// Tests can implement a double for this and bypass stateful window management. +protocol UIViewControllerModalPresenting { + + func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?) + + func dismiss(animated: Bool, completion: (() -> Void)?) +} + +extension UIViewController: UIViewControllerModalPresenting {} diff --git a/19-appendix-c-uikit/Albertos/URLSession+NetworkFetching.swift b/19-appendix-c-uikit/Albertos/URLSession+NetworkFetching.swift new file mode 100644 index 0000000..6f3b0b9 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/URLSession+NetworkFetching.swift @@ -0,0 +1,11 @@ +import Combine +import Foundation + +extension URLSession: NetworkFetching { + + func load(_ request: URLRequest) -> AnyPublisher { + return dataTaskPublisher(for: request) + .map { $0.data } + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/Albertos/UserDefaults+OrderStoring.swift b/19-appendix-c-uikit/Albertos/UserDefaults+OrderStoring.swift new file mode 100644 index 0000000..533ab64 --- /dev/null +++ b/19-appendix-c-uikit/Albertos/UserDefaults+OrderStoring.swift @@ -0,0 +1,22 @@ +import Foundation + +extension UserDefaults: OrderStoring { + + func getOrder() -> Order { + guard let data = data(forKey: orderKey), let order = try? JSONDecoder().decode(Order.self, from: data) else { + let order = Order(items: []) + updateOrder(order) + return order + } + + return order + } + + func updateOrder(_ order: Order) { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(order) else { return } + setValue(data, forKey: orderKey) + } +} + +fileprivate var orderKey = "order" diff --git a/19-appendix-c-uikit/AlbertosTests/AppCoordinatorTests.swift b/19-appendix-c-uikit/AlbertosTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..1845148 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/AppCoordinatorTests.swift @@ -0,0 +1,98 @@ +@testable import Albertos +import Nimble +import XCTest + +class AppCoordinatorTests: XCTestCase { + + func testInitialViewControllerIsNavigationWithMenuList() throws { + let navigationController = UINavigationController() + let coordinator = makeAppCoordinator(with: navigationController) + + coordinator.loadFirstScreen() + + XCTAssertTrue(navigationController.viewControllers.first is MenuListViewController) + } + + func testPushesMenuDetailsOnNavigationStack() { + let navigationController = UINavigationController() + let coordinator = makeAppCoordinator( + with: navigationController + ) + let dummyMenuListVC = MenuListViewController( + menuFetching: MenuFetchingStub( + returning: .success([]) + ) + ) + let item = MenuItem.fixture() + + coordinator.menuListViewController( + dummyMenuListVC, + didSelectItem: item + ) + expect(navigationController.viewControllers.first) + .toEventually( + beAKindOf(MenuItemDetailViewController.self) + ) + } + + func testPresentsOrderDetailOnTopOfNavigationStack() { + let navigationController = UINavigationController() + let coordinator = makeAppCoordinator(with: navigationController) + + // For modal presentation to work, UIKit needs the presenter view controller to be "on + // screen". That's not the case with this `AppCoordinator` instance, because we instantiate + // it outside of the standard application flow. To work around that, let's put the root VC + // in a dedicated window. + let window = UIWindow(frame: UIScreen.main.bounds) + window.makeKeyAndVisible() + window.rootViewController = navigationController + + expect(navigationController.presentedViewController).to(beNil()) + + coordinator.presentOrderDetail() + + // Using `.toEventually` to take animations into account. + // You could refine the setup and inject an `animated` parameter either at the method or + // `AppCoordinator` `init` level, and set it `false` here to avoid that. Set the parameter + // default value `true` to make it seamless in the production code. + expect(navigationController.presentedViewController) + .toEventually(beAKindOf(UINavigationController.self)) + + let presentedNavigationController = + navigationController.presentedViewController as? UINavigationController + + expect(presentedNavigationController?.viewControllers.first) + .to(beAKindOf(OrderDetailViewController.self)) + } + + func testDismissesPresentedVCOnOrderCompletion() { + let navigationController = UINavigationController() + let coordinator = makeAppCoordinator(with: navigationController) + + let window = UIWindow(frame: UIScreen.main.bounds) + window.makeKeyAndVisible() + window.rootViewController = navigationController + + expect(navigationController.presentedViewController).to(beNil()) + + navigationController.present(UIViewController(), animated: false, completion: .none) + + expect(navigationController.presentedViewController).toNot(beNil()) + + let dummyOrderDetailVC = OrderDetailViewController( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessor: PaymentProcessingDummy() + ) + coordinator.orderDetailViewControllerCompletedPaymentFlow(dummyOrderDetailVC) + + expect(navigationController.presentedViewController).toEventually(beNil()) + } + + private func makeAppCoordinator(with navigationController: UINavigationController) -> AppCoordinator { + return AppCoordinator( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessing: PaymentProcessingDummy(), + navigationController: navigationController + ) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/Info.plist b/19-appendix-c-uikit/AlbertosTests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/19-appendix-c-uikit/AlbertosTests/MenuFetcherTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuFetcherTests.swift new file mode 100644 index 0000000..f5bdc7f --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuFetcherTests.swift @@ -0,0 +1,57 @@ +@testable import Albertos +import Combine +import XCTest + +class MenuFetcherTests: XCTestCase { + + var cancellables = Set() + + func testWhenRequestSucceedsPublishesDecodedMenuItems() throws { + let json = """ +[ + { "name": "a name", "category": "a category", "spicy": true, "price": 1.0 }, + { "name": "another name", "category": "a category", "spicy": true, "price": 2.0 } +] +""" + let data = try XCTUnwrap(json.data(using: .utf8)) + let menuFetcher = MenuFetcher(networkFetching: NetworkFetchingStub(returning: .success(data))) + + let expectation = XCTestExpectation(description: "Publishes decoded [MenuItem]") + + menuFetcher.fetchMenu() + .sink( + receiveCompletion: { _ in }, + receiveValue: { items in + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items.first?.name, "a name") + XCTAssertEqual(items.last?.name, "another name") + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func testWhenRequestFailsPublishesReceivedError() { + let expectedError = URLError(.badServerResponse) + let menuFetcher = MenuFetcher(networkFetching: NetworkFetchingStub(returning: .failure(expectedError))) + + let expectation = XCTestExpectation(description: "Publishes received URLError") + + menuFetcher.fetchMenu() + .sink( + receiveCompletion: { completion in + guard case .failure(let error) = completion else { return } + XCTAssertEqual(error as? URLError, expectedError) + expectation.fulfill() + }, + receiveValue: { items in + XCTFail("Expected to fail, succeeded with \(items)") + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuFetchingStub.swift b/19-appendix-c-uikit/AlbertosTests/MenuFetchingStub.swift new file mode 100644 index 0000000..26138d0 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuFetchingStub.swift @@ -0,0 +1,19 @@ +@testable import Albertos +import Combine +import Foundation + +class MenuFetchingStub: MenuFetching { + + let result: Result<[MenuItem], Error> + + init(returning result: Result<[MenuItem], Error>) { + self.result = result + } + + func fetchMenu() -> AnyPublisher<[MenuItem], Error> { + return result.publisher + // Use a delay to simulate the real world async behavior + .delay(for: 0.1, scheduler: RunLoop.main) + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuGroupingTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuGroupingTests.swift new file mode 100644 index 0000000..2d05e90 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuGroupingTests.swift @@ -0,0 +1,44 @@ +@testable import Albertos +import XCTest + +class MenuGroupingTests: XCTestCase { + + func testMenuWithManyCategoriesReturnsAsManySectionsInReverseAlphabeticalOrder() { + let menu: [MenuItem] = [ + .fixture(category: "pastas"), + .fixture(category: "drinks"), + .fixture(category: "pastas"), + .fixture(category: "desserts"), + ] + + let sections = groupMenuByCategory(menu) + + XCTAssertEqual(sections.count, 3) + XCTAssertEqual(sections[safe: 0]?.category, "pastas") + XCTAssertEqual(sections[safe: 1]?.category, "drinks") + XCTAssertEqual(sections[safe: 2]?.category, "desserts") + } + + func testMenuWithOneCategoryReturnsOneSection() throws { + let menu: [MenuItem] = [ + .fixture(category: "pastas", name: "name"), + .fixture(category: "pastas", name: "other name") + ] + + let sections = groupMenuByCategory(menu) + + XCTAssertEqual(sections.count, 1) + let section = try XCTUnwrap(sections.first) + XCTAssertEqual(section.items.count, 2) + XCTAssertEqual(section.items.first?.name, "name") + XCTAssertEqual(section.items.last?.name, "other name") + } + + func testEmptyMenuReturnsEmptySections() { + let menu = [MenuItem]() + + let sections = groupMenuByCategory(menu) + + XCTAssertEqual(sections.count, 0) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItem+Fixture.swift b/19-appendix-c-uikit/AlbertosTests/MenuItem+Fixture.swift new file mode 100644 index 0000000..036d8ef --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItem+Fixture.swift @@ -0,0 +1,13 @@ +@testable import Albertos + +extension MenuItem { + + static func fixture( + category: String = "category", + name: String = "name", + spicy: Bool = false, + price: Double = 1.0 + ) -> MenuItem { + MenuItem(category: category, name: name, spicy: spicy, price: price) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItem+JSONFixture.swift b/19-appendix-c-uikit/AlbertosTests/MenuItem+JSONFixture.swift new file mode 100644 index 0000000..adadb70 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItem+JSONFixture.swift @@ -0,0 +1,20 @@ +@testable import Albertos + +extension MenuItem { + + static func jsonFixture( + name: String = "a name", + category: String = "a category", + spicy: Bool = false, + price: Double = 1.0 + ) -> String { + return """ +{ + "name": "\(name)", + "category": "\(category)", + "spicy": \(spicy), + "price": \(price) +} +""" + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItemAlternateJSONTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuItemAlternateJSONTests.swift new file mode 100644 index 0000000..4ddbd04 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItemAlternateJSONTests.swift @@ -0,0 +1,57 @@ +// +// This is an example of how to decode models that don't match their JSON input. +// To avoid polluting the source code, we define the alternate MenuItem here. +// +// If you want to verify the failure, uncomment the import of the production module and comment the +// definition of MenuItem in this file +//@testable import Albertos +import XCTest + +private struct MenuItem: Decodable { + var category: String { categoryObject.name } + let name: String + let spicy: Bool + let price: Double + + private let categoryObject: Category + + enum CodingKeys: String, CodingKey { + case name, spicy, price + case categoryObject = "category" + } + + struct Category: Decodable { + let name: String + } +} + +class MenuItemAlternateJSONTests: XCTestCase { + + func testWhenDecodedFromJSONDataHasAllTheInputProperties() throws { + let json = """ +{ + "name": "a name", + "category": { + "name": "a category", + "id": 123 + }, + "spicy": false, + "price": 1.0 +} +""" + let data = try XCTUnwrap(json.data(using: .utf8)) + + let item: MenuItem + do { + item = try JSONDecoder().decode(MenuItem.self, from: data) + } catch { + XCTFail("\(error)") + return + } + + XCTAssertEqual(item.name, "a name") + XCTAssertEqual(item.category, "a category") + XCTAssertEqual(item.spicy, false) + XCTAssertEqual(item.price, 1.0) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItemDetail.ViewModelTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuItemDetail.ViewModelTests.swift new file mode 100644 index 0000000..91d436e --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItemDetail.ViewModelTests.swift @@ -0,0 +1,84 @@ +@testable import Albertos +import XCTest + +class MenuItemDetailViewModelTests: XCTestCase { + + func testWhenItemIsInOrderButtonSaysRemove() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: item) + let viewModel = MenuItemDetailViewModel(item: item, orderController: orderController) + + let text = viewModel.addOrRemoveFromOrderButtonText + + XCTAssertEqual(text, "Remove from order") + } + + func testWhenItemIsNotInOrderButtonSaysAdd() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + let viewModel = MenuItemDetailViewModel(item: item, orderController: orderController) + + let text = viewModel.addOrRemoveFromOrderButtonText + + XCTAssertEqual(text, "Add to order") + } + + func testWhenItemIsInOrderButtonActionRemovesIt() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: item) + let viewModel = MenuItemDetailViewModel(item: item, orderController: orderController) + + viewModel.addOrRemoveFromOrder() + + XCTAssertFalse(orderController.order.items.contains { $0 == item }) + } + + func testWhenItemIsNotInOrderButtonActionAddsIt() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + let viewModel = MenuItemDetailViewModel(item: item, orderController: orderController) + + viewModel.addOrRemoveFromOrder() + + XCTAssertTrue(orderController.order.items.contains { $0 == item }) + } + + func testNameIsItemName() { + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(name: "a name"), orderController: OrderController()).name, + "a name" + ) + } + + func testWhenItemIsSpicyShowsSpicyMessage() { + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(spicy: true), orderController: OrderController()).spicy, + "Spicy" + ) + } + + func testWhenItemIsNotSpicyDoesNotShowSpicyMessage() { + XCTAssertNil(MenuItemDetailViewModel(item: .fixture(spicy: false), orderController: OrderController()).spicy) + } + + func testPriceIsFormattedItemPrice() { + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(price: 1.0), orderController: OrderController()).price, + "$1.00" + ) + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(price: 2.5), orderController: OrderController()).price, + "$2.50" + ) + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(price: 3.45), orderController: OrderController()).price, + "$3.45" + ) + XCTAssertEqual( + MenuItemDetailViewModel(item: .fixture(price: 4.123), orderController: OrderController()).price, + "$4.12" + ) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewControllerTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewControllerTests.swift new file mode 100644 index 0000000..c301ab1 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewControllerTests.swift @@ -0,0 +1,57 @@ +@testable import Albertos +import XCTest + +class MenuItemDetailViewControllerTests: XCTestCase { + + func testConfiguresViewWithViewModel() { + let viewModel = MenuItemDetailViewModel( + item: MenuItem.fixture(), + orderController: OrderController(orderStoring: OrderStoringFake()) + ) + let viewController = MenuItemDetailViewController(viewModel: viewModel) + _ = viewController.view + + XCTAssertEqual(viewController.containerView.nameLabel.text, viewModel.name) + XCTAssertEqual(viewController.containerView.priceLabel.text, viewModel.price) + XCTAssertEqual( + viewController.containerView.addOrRemoveFromOrderButton.title(for: .normal), + viewModel.addOrRemoveFromOrderButtonText + ) + XCTAssertEqual(viewController.containerView.spicyLabel.text, viewModel.spicy) + } + + func testUpdatesOrderWhenButtonActioned() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + let viewController = MenuItemDetailViewController( + viewModel: .init(item: item, orderController: orderController) + ) + _ = viewController.view + + viewController.containerView.addOrRemoveFromOrderButton.sendActions(for: .touchUpInside) + + XCTAssertTrue(orderController.order.items.contains(item)) + + viewController.containerView.addOrRemoveFromOrderButton.sendActions(for: .touchUpInside) + + XCTAssertFalse(orderController.order.items.contains(item)) + } + + func testUpdatesViewAfterButtonActioned() { + let item = MenuItem.fixture() + let orderController = OrderController(orderStoring: OrderStoringFake()) + let menuItemDetailVC = MenuItemDetailViewController( + viewModel: .init(item: item, orderController: orderController) + ) + _ = menuItemDetailVC.view + + let initialValue = menuItemDetailVC.containerView.addOrRemoveFromOrderButton.title(for: .normal) + + menuItemDetailVC.containerView.addOrRemoveFromOrderButton.sendActions(for: .touchUpInside) + + XCTAssertNotEqual( + menuItemDetailVC.containerView.addOrRemoveFromOrderButton.title(for: .normal), + initialValue + ) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewTests.swift new file mode 100644 index 0000000..4a4f6c8 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItemDetailViewTests.swift @@ -0,0 +1,35 @@ +@testable import Albertos +import XCTest + +class MenuItemDetailViewTests: XCTestCase { + + func testWhenViewModelHasSpicyNilDoesNotAddSpicyLabel() { + let viewModel = MenuItemDetailViewModel( + item: .fixture(spicy: false), + orderController: OrderController(orderStoring: OrderStoringFake()) + ) + // Because we can only set the spicy property of the ViewModel indirectly via its item, + // let's make sure it matches our assumption before proceeding with the test. + XCTAssertNil(viewModel.spicy) + + let view = MenuItemDetailView() + + view.configureContent(with: viewModel) + + XCTAssertFalse(view.arrangedSubviews.contains(view.spicyLabel)) + } + + func testWhenViewModelHasSpicyNonNilAddsSpicyLabel() { + let viewModel = MenuItemDetailViewModel( + item: .fixture(spicy: true), + orderController: OrderController(orderStoring: OrderStoringFake()) + ) + XCTAssertNotNil(viewModel.spicy) + + let view = MenuItemDetailView() + + view.configureContent(with: viewModel) + + XCTAssertTrue(view.arrangedSubviews.contains(view.spicyLabel)) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuItemTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuItemTests.swift new file mode 100644 index 0000000..3ecd15b --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuItemTests.swift @@ -0,0 +1,68 @@ +@testable import Albertos +import XCTest + +class MenuItemTests: XCTestCase { + + // MARK: Inline example with Triangulation + + func testWhenDecodedFromJSONDataHasAllTheInputPropertiesExample1() throws { + let json = #"{ "name": "a name", "category": "a category", "spicy": true, "price": 1.0 }"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + let item = try JSONDecoder().decode(MenuItem.self, from: data) + + XCTAssertEqual(item.name, "a name") + XCTAssertEqual(item.category, "a category") + XCTAssertEqual(item.spicy, true) + XCTAssertEqual(item.price, 1.0) + } + + func testWhenDecodedFromJSONDataHasAllTheInputPropertiesExample2() throws { + let json = #"{ "name": "another name", "category": "another category", "spicy": false, "price": 2.0 }"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + let item = try JSONDecoder().decode(MenuItem.self, from: data) + + XCTAssertEqual(item.name, "another name") + XCTAssertEqual(item.category, "another category") + XCTAssertEqual(item.spicy, false) + XCTAssertEqual(item.price, 2.0) + } + + // MARK: Inline example with helper function + + func testWhenDecodedFromJSONDataHasAllTheInputProperties_HelperFunction() throws { + let json = MenuItem.jsonFixture(name: "a name", category: "a category", spicy: false, price: 1.0) + let data = try XCTUnwrap(json.data(using: .utf8)) + + let item = try JSONDecoder().decode(MenuItem.self, from: data) + + XCTAssertEqual(item.name, "a name") + XCTAssertEqual(item.category, "a category") + XCTAssertEqual(item.spicy, false) + XCTAssertEqual(item.price, 1.0) + } + + // MARK: From JSON file example + + func testWhenDecodedFromJSONDataHasAllTheInputProperties_JSONFile() throws { + let data = try dataFromJSONFileNamed("menu_item") + + let item = try JSONDecoder().decode(MenuItem.self, from: data) + + XCTAssertEqual(item.name, "a name") + XCTAssertEqual(item.category, "a category") + XCTAssertEqual(item.spicy, true) + XCTAssertEqual(item.price, 1.0) + } + + // MARK: Simpler check example + // Use this option if your models match the shape of the input JSON. + + func testWhenDecodingFromJSONDataDoesNotThrow() throws { + let json = #"{ "name": "a name", "category": "a category", "spicy": true, "price": 1.0 }"# + let data = try XCTUnwrap(json.data(using: .utf8)) + + XCTAssertNoThrow(try JSONDecoder().decode(MenuItem.self, from: data)) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuList.ViewModelTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuList.ViewModelTests.swift new file mode 100644 index 0000000..5cb671b --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuList.ViewModelTests.swift @@ -0,0 +1,74 @@ +@testable import Albertos +import Combine +import XCTest + +class MenuListViewModelTests: XCTestCase { + + var cancellables = Set() + + func testWhenFetchingStartsPublishesEmptyMenu() throws { + let viewModel = MenuListViewModel(menuFetching: MenuFetchingStub(returning: .success([]))) + + XCTAssertTrue(try viewModel.sections.get().isEmpty) + } + + func testWhenFecthingSucceedsPublishesSectionsBuiltFromReceivedMenuAndGivenGroupingClosure() { + var receivedMenu: [MenuItem]? + let expectedSections = [MenuSection.fixture()] + let spyClosure: ([MenuItem]) -> [MenuSection] = { items in receivedMenu = items + return expectedSections + } + + let expectedMenu = [MenuItem.fixture()] + let menuFetchingStub = MenuFetchingStub(returning: .success(expectedMenu)) + + let viewModel = MenuListViewModel(menuFetching: menuFetchingStub, menuGrouping: spyClosure) + + let expectation = XCTestExpectation( + description: "Publishes sections built from received menu and given grouping closure" + ) + viewModel + .$sections + .dropFirst() + .sink { value in + guard case .success(let sections) = value else { + return XCTFail("Expected a successful Result, got: \(value)") + } + + // Ensure the grouping closure is called with the received menu + XCTAssertEqual(receivedMenu, expectedMenu) + // Ensure the published value is the result of the grouping closure + XCTAssertEqual(sections, expectedSections) + expectation.fulfill() + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func testWhenFetchingFailsPublishesAnError() { + let expectedError = TestError(id: 123) + let menuFetchingStub = MenuFetchingStub(returning: .failure(expectedError)) + let viewModel = MenuListViewModel( + menuFetching: menuFetchingStub, + menuGrouping: { _ in [] } + ) + + let expectation = XCTestExpectation(description: "Publishes an error") + + viewModel + .$sections + .dropFirst() + .sink { value in + guard case .failure(let error) = value else { + return XCTFail("Expected a failing Result, got: \(value)") + } + + XCTAssertEqual(error as? TestError, expectedError) + expectation.fulfill() + } + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuListTableViewDataSourceTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuListTableViewDataSourceTests.swift new file mode 100644 index 0000000..ad3258b --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuListTableViewDataSourceTests.swift @@ -0,0 +1,79 @@ +@testable import Albertos +import XCTest + +class MenuListTableViewDataSourceTests: XCTestCase { + + func testWhenViewModelSectionsIsErrorSectionNumberIsOne() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload(tableView, with: .failure(TestError(id: 1))) + + XCTAssertEqual(tableView.numberOfSections, 1) + } + + func testWhenViewModelSectionsIsSuccessSectionNumberIsNumberOfSections() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload( + tableView, + with: .success( + [ + .fixture(category: "a category"), + .fixture(category: "another category") + ] + ) + ) + + XCTAssertEqual(tableView.numberOfSections, 2) + } + + func testWhenViewModelSectionsIsErrorNumberOfRowsInSectionIsOne() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload(tableView, with: .failure(TestError(id: 1))) + + XCTAssertEqual(tableView.numberOfRows(inSection: 0), 1) + } + + func testWhenViewModelSectionsIsSuccessNumberOfRowsInSectionIsSectionItemsCount() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload(tableView, with: .success([.fixture(items: [.fixture(), .fixture()])])) + + XCTAssertEqual(tableView.numberOfRows(inSection: 0), 2) + } + + func testWhenViewModelSectionsIsErrorCellTextShowsError() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload(tableView, with: .failure(TestError(id: 1))) + + XCTAssertEqual( + tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.textLabel?.text, + "An error occurred" + ) + } + + func testWhenViewModelSectionsIsSuccessCellShowsItemName() { + let dataSource = MenuListTableViewDataSource() + let tableView = UITableView(frame: UIScreen.main.bounds) + dataSource.setAsDataSourceOf(tableView) + + dataSource.reload(tableView, with: .success([.fixture(items: [.fixture(name: "a name")])])) + + XCTAssertEqual( + tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.textLabel?.text, + "a name" + ) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuListViewControllerTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuListViewControllerTests.swift new file mode 100644 index 0000000..523e396 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuListViewControllerTests.swift @@ -0,0 +1,22 @@ +@testable import Albertos +import Nimble +import XCTest + +class MenuListViewControllerTests: XCTestCase { + + func testWhenNewDataArrivesUpdatesTableView() { + let vc = MenuListViewController( + menuFetching: MenuFetchingStub(returning: .success([.fixture(name: "a name")])) + ) + _ = vc.view + // UITableView only loads cells if they are visible, so we need to force the + // ViewController's view to be big enough for the cell we're inspecting to be rendered. + vc.view.layoutIfNeeded() + + expect(vc.tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.textLabel?.text) + .to(beNil()) + + expect(vc.tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.textLabel?.text) + .toEventually(equal("a name")) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuRow.ViewModelTests.swift b/19-appendix-c-uikit/AlbertosTests/MenuRow.ViewModelTests.swift new file mode 100644 index 0000000..dc6a750 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuRow.ViewModelTests.swift @@ -0,0 +1,17 @@ +@testable import Albertos +import XCTest + +class MenuRowViewModelTests: XCTestCase { + + func testWhenItemIsNotSpicyTextIsItemNameOnly() { + let item = MenuItem.fixture(name: "name", spicy: false) + let viewModel = MenuRowViewModel(item: item) + XCTAssertEqual(viewModel.text, "name") + } + + func testWhenItemIsSpicyTextIsItemNameWithChiliEmoji() { + let item = MenuItem.fixture(name: "name", spicy: true) + let viewModel = MenuRowViewModel(item: item) + XCTAssertEqual(viewModel.text, "name 🔥") + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MenuSection+Fixture.swift b/19-appendix-c-uikit/AlbertosTests/MenuSection+Fixture.swift new file mode 100644 index 0000000..c08d0cb --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MenuSection+Fixture.swift @@ -0,0 +1,11 @@ +@testable import Albertos + +extension MenuSection { + + static func fixture( + category: String = "a category", + items: [MenuItem] = [.fixture()] + ) -> MenuSection { + return MenuSection(category: category, items: items) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/MeunListTableViewDelegateTests.swift b/19-appendix-c-uikit/AlbertosTests/MeunListTableViewDelegateTests.swift new file mode 100644 index 0000000..035cb99 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/MeunListTableViewDelegateTests.swift @@ -0,0 +1,26 @@ +@testable import Albertos +import XCTest + +class MenuListTableViewDelegateTests: XCTestCase { + + func testWhenSectionsIsFailureDoesNotCallSelectionCallback() { + var called = false + let delegate = MenuListTableViewDelegate(onRowSelected: { _ in called = true }) + delegate.sections = .failure(TestError(id: 1)) + + delegate.tableView(UITableView(), didSelectRowAt: IndexPath(row: 0, section: 0)) + + XCTAssertFalse(called) + } + + func testWhenSectionsIsSuccessCallSelectionCallbackWithMatchingItem() { + var receivedItem: MenuItem? + let delegate = MenuListTableViewDelegate(onRowSelected: { receivedItem = $0 }) + let item = MenuItem.fixture(name: "a name") + delegate.sections = .success([.fixture(items: [.fixture(name: "another name"), item])]) + + delegate.tableView(UITableView(), didSelectRowAt: IndexPath(row: 1, section: 0)) + + XCTAssertEqual(receivedItem, item) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/NetworkFetchingStub.swift b/19-appendix-c-uikit/AlbertosTests/NetworkFetchingStub.swift new file mode 100644 index 0000000..de00dd8 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/NetworkFetchingStub.swift @@ -0,0 +1,19 @@ +@testable import Albertos +import Combine +import Foundation + +class NetworkFetchingStub: NetworkFetching { + + private let result: Result + + init(returning result: Result) { + self.result = result + } + + func load(_ request: URLRequest) -> AnyPublisher { + return result.publisher + // Use a delay to simulate the real world async behavior + .delay(for: 0.01, scheduler: RunLoop.main) + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/OrderButtonViewModelTests.swift b/19-appendix-c-uikit/AlbertosTests/OrderButtonViewModelTests.swift new file mode 100644 index 0000000..74ff071 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/OrderButtonViewModelTests.swift @@ -0,0 +1,22 @@ +@testable import Albertos +import XCTest + +class OrderButtonViewModelTests: XCTestCase { + + func testWhenOrderIsEmptyDoesNotShowTotal() { + let orderController = OrderController(orderStoring: OrderStoringFake()) + let viewModel = OrderButtonViewModel(orderController: orderController) + + XCTAssertEqual(viewModel.text, "Your Order") + } + + func testWhenOrderIsNotEmptyShowsTotal() { + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture(price: 1.0)) + orderController.addToOrder(item: .fixture(price: 2.3)) + let viewModel = OrderButtonViewModel(orderController: orderController) + + XCTAssertEqual(viewModel.text, "Your Order $3.30") + } +} + diff --git a/19-appendix-c-uikit/AlbertosTests/OrderControllerTests.swift b/19-appendix-c-uikit/AlbertosTests/OrderControllerTests.swift new file mode 100644 index 0000000..5bbc600 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/OrderControllerTests.swift @@ -0,0 +1,48 @@ +@testable import Albertos +import XCTest + +class OrderControllerTests: XCTestCase { + + func testInitsWithEmptyOrder() { + let controller = OrderController(orderStoring: OrderStoringFake()) + + XCTAssertTrue(controller.order.items.isEmpty) + } + + func testWhenItemNotInOrderReturnsFalse() { + let controller = OrderController(orderStoring: OrderStoringFake()) + controller.addToOrder(item: .fixture(name: "a name")) + + XCTAssertFalse(controller.isItemInOrder(.fixture(name: "another name"))) + } + + func testWhenItemInOrderReturnsTrue() { + let controller = OrderController(orderStoring: OrderStoringFake()) + controller.addToOrder(item: .fixture(name: "a name")) + + XCTAssertTrue(controller.isItemInOrder(.fixture(name: "a name"))) + } + + func testAddingItemUpdatesOrder() { + let controller = OrderController(orderStoring: OrderStoringFake()) + + let item = MenuItem.fixture() + controller.addToOrder(item: item) + + XCTAssertEqual(controller.order.items.count, 1) + XCTAssertEqual(controller.order.items.first, item) + } + + func testRemovingItemUpdatesOrder() { + let item = MenuItem.fixture(name: "a name") + let otherItem = MenuItem.fixture(name: "another name") + let controller = OrderController(orderStoring: OrderStoringFake()) + controller.addToOrder(item: item) + controller.addToOrder(item: otherItem) + + controller.removeFromOrder(item: item) + + XCTAssertEqual(controller.order.items.count, 1) + XCTAssertEqual(controller.order.items.first, otherItem) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/OrderDetail.ViewModelTests.swift b/19-appendix-c-uikit/AlbertosTests/OrderDetail.ViewModelTests.swift new file mode 100644 index 0000000..39e0332 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/OrderDetail.ViewModelTests.swift @@ -0,0 +1,167 @@ +@testable import Albertos +import XCTest + +class OrderDetailViewModelTests: XCTestCase { + + let alertDismissDummy: () -> Void = {} + + func testWhenCheckoutButtonPressedStartsPaymentProcessingFlow() { + // Create an OrderController and add some items to it + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture(name: "name")) + orderController.addToOrder(item: .fixture(name: "other name")) + // Create the Spy + let paymentProcessingSpy = PaymentProcessingSpy() + + let viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: paymentProcessingSpy, + onAlertDismiss: alertDismissDummy + ) + + viewModel.checkout() + + XCTAssertEqual(paymentProcessingSpy.receivedOrder, orderController.order) + } + + // Because testing with NSPredicate is slow, we use the same test scaffold to test two + // behaviors. When the payment succeeded the ViewModel updates its `alertToShow` property: + // + // - with the expected settings for the success confirmation + // - with the given callback to run as the button action + // - when the callback runs, the order is reset + func testWhenPaymentSucceedsUpdatesPropertyToShowConfirmationAlertThatCallsDimissCallback() { + // Arrange the input state with a valid order, one that has items + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture()) + + // Set a spy value for the dismiss callback + var called = false + let viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: PaymentProcessingStub(returning: .success(())), + onAlertDismiss: { called = true } + ) + + let predicate = NSPredicate { _, _ in viewModel.alertToShow != nil } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: .none) + + viewModel.checkout() + + wait(for: [expectation], timeout: timeoutForPredicateExpectations) + + XCTAssertEqual(viewModel.alertToShow?.title, "") + XCTAssertEqual( + viewModel.alertToShow?.message, + "The payment was successful. Your food will be with you shortly." + ) + XCTAssertEqual(viewModel.alertToShow?.buttonText, "Ok") + + viewModel.alertToShow?.buttonAction?() + XCTAssertTrue(called) + + // Verify the order has been reset + XCTAssertTrue(orderController.order.items.isEmpty) + } + + // Because testing with NSPredicate is slow, we use the same test scaffold to test two + // behaviors. When the payment succeeded the ViewModel updates its `alertToShow` property: + // + // - with the expected settings for the success confirmation + // - with the given callback to run as the button action + func testWhenPaymentFailsUpdatesPropertyToShowErrorAlertThatCallsDismissCallback() { + var called = false + let viewModel = OrderDetailViewModel( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessor: PaymentProcessingStub(returning: .failure(TestError(id: 123))), + onAlertDismiss: { called = true } + ) + + let predicate = NSPredicate { _, _ in viewModel.alertToShow != nil } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: .none) + + viewModel.checkout() + + wait(for: [expectation], timeout: timeoutForPredicateExpectations) + + XCTAssertEqual(viewModel.alertToShow?.title, "") + XCTAssertEqual( + viewModel.alertToShow?.message, + "There's been an error with your order. Please contact a waiter." + ) + XCTAssertEqual(viewModel.alertToShow?.buttonText, "Ok") + + viewModel.alertToShow?.buttonAction?() + XCTAssertTrue(called) + } + + func testWhenOrderIsEmptyShouldNotShowTotalAmount() { + let viewModel = OrderDetailViewModel( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertNil(viewModel.totalText) + } + + func testWhenOrderIsNonEmptyShouldShowTotalAmount() { + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture(price: 1.0)) + orderController.addToOrder(item: .fixture(price: 2.3)) + let viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertEqual(viewModel.totalText, "Total: $3.30") + } + + func testWhenOrderIsEmptyHasNotItemNamesToShow() { + let viewModel = OrderDetailViewModel( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertEqual(viewModel.menuListItems.count, 0) + } + + func testWhenOrderIsEmptyDoesNotShowCheckoutButton() { + let viewModel = OrderDetailViewModel( + orderController: OrderController(orderStoring: OrderStoringFake()), + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertFalse(viewModel.shouldShowCheckoutButton) + } + + func testWhenOrderIsNonEmptyMenuListItemIsOrderItems() { + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture(name: "a name")) + orderController.addToOrder(item: .fixture(name: "another name")) + let viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertEqual(viewModel.menuListItems.count, 2) + XCTAssertEqual(viewModel.menuListItems.first?.name, "a name") + XCTAssertEqual(viewModel.menuListItems.last?.name, "another name") + } + + func testWhenOrderIsNonEmptyShowsCheckoutButton() { + let orderController = OrderController(orderStoring: OrderStoringFake()) + orderController.addToOrder(item: .fixture(name: "a name")) + let viewModel = OrderDetailViewModel( + orderController: orderController, + paymentProcessor: PaymentProcessingDummy(), + onAlertDismiss: alertDismissDummy + ) + + XCTAssertTrue(viewModel.shouldShowCheckoutButton) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/OrderStoringFake.swift b/19-appendix-c-uikit/AlbertosTests/OrderStoringFake.swift new file mode 100644 index 0000000..a36caf8 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/OrderStoringFake.swift @@ -0,0 +1,14 @@ +@testable import Albertos + +class OrderStoringFake: OrderStoring { + + private var order: Order = Order(items: []) + + func getOrder() -> Order { + return order + } + + func updateOrder(_ order: Order) { + self.order = order + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/OrderTests.swift b/19-appendix-c-uikit/AlbertosTests/OrderTests.swift new file mode 100644 index 0000000..d9bde21 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/OrderTests.swift @@ -0,0 +1,26 @@ +@testable import Albertos +import XCTest + +class OrderTests: XCTestCase { + + func testTotalSumsPricesOfEachItem() { + let order = Order( + items: [.fixture(price: 1.0), .fixture(price: 2.0), .fixture(price: 3.5)] + ) + + XCTAssertEqual(order.total, 6.5) + } + + func testHippoPaymentsPayloadHasOrderItemsNames() throws { + let order = Order( + items: [.fixture(name: "a name"), .fixture(name: "other name")] + ) + + let payload = order.hippoPaymentsPayload + + let payloadItems = try XCTUnwrap(payload["items"] as? [String]) + XCTAssertEqual(payloadItems.count, 2) + XCTAssertEqual(payloadItems.first, "a name") + XCTAssertEqual(payloadItems.last, "other name") + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/PaymentProcessingDummy.swift b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingDummy.swift new file mode 100644 index 0000000..cb7c0ab --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingDummy.swift @@ -0,0 +1,12 @@ +@testable import Albertos +import Combine + +class PaymentProcessingDummy: PaymentProcessing { + + // When implementing a dummy that has to return a result, like in this case, choose the simplest + // code you can to make the it compile. Because dummies are meant to be used as placeholder + // only, it doesn't matter what output they provide. + func process(order: Order) -> AnyPublisher { + return Result.success(()).publisher.eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/PaymentProcessingSpy.swift b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingSpy.swift new file mode 100644 index 0000000..e87dfa6 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingSpy.swift @@ -0,0 +1,13 @@ +@testable import Albertos +import Combine + +class PaymentProcessingSpy: PaymentProcessing { + + private(set) var receivedOrder: Order? + + func process(order: Order) -> AnyPublisher { + receivedOrder = order + + return Result.success(()).publisher.eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/PaymentProcessingStub.swift b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingStub.swift new file mode 100644 index 0000000..5abfde2 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/PaymentProcessingStub.swift @@ -0,0 +1,19 @@ +@testable import Albertos +import Combine +import Foundation + +class PaymentProcessingStub: PaymentProcessing { + + let result: Result + + init(returning result: Result) { + self.result = result + } + + func process(order: Order) -> AnyPublisher { + return result.publisher + // Use a delay to simulate the real world async behavior + .delay(for: 0.01, scheduler: RunLoop.main) + .eraseToAnyPublisher() + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/SceneDelegateTests.swift b/19-appendix-c-uikit/AlbertosTests/SceneDelegateTests.swift new file mode 100644 index 0000000..7af51d7 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/SceneDelegateTests.swift @@ -0,0 +1,90 @@ +//@testable import Albertos +//import Nimble +//import XCTest +// +//class SceneDelegateTests: XCTestCase { +// +// // TODO: Need better name +// func testShowsMenuDetailViewControllerOnNavigationStack() { +// let sceneDelegate = SceneDelegate() +// let dummyMenuListVC = MenuListViewController(menuFetching: MenuFetchingStub(returning: .success([]))) +// let item = MenuItem.fixture() +// +// sceneDelegate.menuListViewController(dummyMenuListVC, didSelectItem: item) +// +// // Using `.toEventually` to take animations into account. +// // You could refine the setup and inject an `animated` parameter either at the method or +// // `SceneDelegate` `init` level, and set it `false` here to avoid that. Set the parameter +// // default value `true` to make it seamless in the production code. +// expect(sceneDelegate.navigationController.viewControllers.first) +// .toEventually(beAKindOf(MenuItemDetailViewController.self)) +// } +// +// // TODO: Need better name +// func testPresentsOrderDetailOnTopOfNavigationStack() { +// let sceneDelegate = SceneDelegate() +// +// // For modal presentation to work, UIKit needs the presenter view controller to be "on +// // screen". That's not the case with this `SceneDelegate` instance, because we do not call +// // the `scene(_:, willConnectTo:, options:)` method. To work around that, let's put the +// // `SceneDelegate` root VC in a dedicated window. +// // +// // An alternative approach would be using DIP and having an abstraction layer to describe a +// // component capable of modally presenting view controllers. E.g.: +// // +// // ``` +// // protocol ViewControllerPresenting { +// // func present(_ viewController: UIViewController, animated: Bool, ...) +// // } +// // ``` +// // +// // We could then build a spy test double and use it to verify the presented VC. That would +// // make the tests faster and less dependent on finicky UIKit requirements. +// let window = UIWindow(frame: UIScreen.main.bounds) +// window.makeKeyAndVisible() +// window.rootViewController = sceneDelegate.navigationController +// // This is also required, it loads the navigation controller view. +// _ = sceneDelegate.navigationController.view +// +// expect(sceneDelegate.navigationController.presentedViewController).to(beNil()) +// +// sceneDelegate.presentOrderDetail() +// +// // Using `.toEventually` to take animations into account. +// // You could refine the setup and inject an `animated` parameter either at the method or +// // `SceneDelegate` `init` level, and set it `false` here to avoid that. Set the parameter +// // default value `true` to make it seamless in the production code. +// expect(sceneDelegate.navigationController.presentedViewController) +// .toEventually(beAKindOf(UINavigationController.self)) +// +// let presentedNavigationController = +// sceneDelegate.navigationController.presentedViewController as? UINavigationController +// +// expect(presentedNavigationController?.viewControllers.first) +// .to(beAKindOf(OrderDetailViewController.self)) +// } +// +// // TODO: Need better name +// func testDismissesPresentedVCOnOrderCompletion() { +// let sceneDelegate = SceneDelegate() +// +// let window = UIWindow(frame: UIScreen.main.bounds) +// window.makeKeyAndVisible() +// window.rootViewController = sceneDelegate.navigationController +// _ = sceneDelegate.navigationController.view +// +// expect(sceneDelegate.navigationController.presentedViewController).to(beNil()) +// +// sceneDelegate.navigationController.present(UIViewController(), animated: false, completion: .none) +// +// expect(sceneDelegate.navigationController.presentedViewController).toNot(beNil()) +// +// let dummyOrderDetailVC = OrderDetailViewController( +// orderController: OrderController(orderStoring: OrderStoringFake()), +// paymentProcessor: PaymentProcessingDummy() +// ) +// sceneDelegate.orderDetailViewControllerCompletedPaymentFlow(dummyOrderDetailVC) +// +// expect(sceneDelegate.navigationController.presentedViewController).toEventually(beNil()) +// } +//} diff --git a/19-appendix-c-uikit/AlbertosTests/TestError.swift b/19-appendix-c-uikit/AlbertosTests/TestError.swift new file mode 100644 index 0000000..bdeb99d --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/TestError.swift @@ -0,0 +1,3 @@ +struct TestError: Equatable, Error { + let id: Int +} diff --git a/19-appendix-c-uikit/AlbertosTests/XCTestCase+JSON.swift b/19-appendix-c-uikit/AlbertosTests/XCTestCase+JSON.swift new file mode 100644 index 0000000..9bbfa4d --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/XCTestCase+JSON.swift @@ -0,0 +1,11 @@ +import XCTest + +extension XCTestCase { + + func dataFromJSONFileNamed(_ name: String) throws -> Data { + let url = try XCTUnwrap( + Bundle(for: type(of: self)).url(forResource: name, withExtension: "json") + ) + return try Data(contentsOf: url) + } +} diff --git a/19-appendix-c-uikit/AlbertosTests/XCTestCase+Timeouts.swift b/19-appendix-c-uikit/AlbertosTests/XCTestCase+Timeouts.swift new file mode 100644 index 0000000..d48c4d7 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/XCTestCase+Timeouts.swift @@ -0,0 +1,8 @@ +import XCTest + +extension XCTestCase { + + /// Using a wait time of around 1 second seems to result in occasional + /// test timeout failures when using `XCTNSPredicateExpectation`s. + var timeoutForPredicateExpectations: Double { 2.0 } +} diff --git a/19-appendix-c-uikit/AlbertosTests/menu_item.json b/19-appendix-c-uikit/AlbertosTests/menu_item.json new file mode 100644 index 0000000..066e43f --- /dev/null +++ b/19-appendix-c-uikit/AlbertosTests/menu_item.json @@ -0,0 +1,6 @@ +{ + "name": "a name", + "category": "a category", + "spicy": true, + "price": 1.0 +} diff --git a/19-appendix-c-uikit/AlbertosUITests/AlbertosUITests.swift b/19-appendix-c-uikit/AlbertosUITests/AlbertosUITests.swift new file mode 100644 index 0000000..a360153 --- /dev/null +++ b/19-appendix-c-uikit/AlbertosUITests/AlbertosUITests.swift @@ -0,0 +1,14 @@ +import XCTest + +class AlbertosUITests: XCTestCase { + + func testExample() throws { + // Making the tests fail intentionally while there's no real test so we don't accidentally + // add them to the scheme that runs the unit tests only without noticing. + // + // Practicing Test-Driven Development is all about establishing as fast a feedback cycle as + // possible. Running tests that are slow to launch and on top of that do nothing is an + // unnecessary drag. + XCTFail("No test implemented yet!") + } +} diff --git a/19-appendix-c-uikit/AlbertosUITests/Info.plist b/19-appendix-c-uikit/AlbertosUITests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/19-appendix-c-uikit/AlbertosUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/19-appendix-c-uikit/BuildPhases/xcsort b/19-appendix-c-uikit/BuildPhases/xcsort new file mode 100755 index 0000000..60be4cd --- /dev/null +++ b/19-appendix-c-uikit/BuildPhases/xcsort @@ -0,0 +1,58 @@ +#!/bin/sh +# See https://github.com/yusuke024/xcsort/blob/34cd6bdb378f21412cfc96b32ef339437e5f0e02/xcsort + +set -e + +if [[ $# -gt 1 ]] +then + echo "Not support" + exit 1 +fi + +# If your .xcodeproj file doesn't live immediately under SRCROOT or has a different name change the line below +DEFAULT_PROJECT_FILE_PATH="${SRCROOT}/${PROJECT_NAME}.xcodeproj/project.pbxproj" +PROJECT_FILE_PATH=${1:-"$DEFAULT_PROJECT_FILE_PATH"} +TEMP_FILE_PATH=$(mktemp "${TMPDIR:-/tmp}/xc.$$.XXXXXXXXXX") + +if [[ ! -f $PROJECT_FILE_PATH ]] +then + echo "Cannot read $PROJECT_FILE_PATH" + exit 1 +fi + +sort_range() { + while read -r + do + printf "%s\n" "$REPLY" + if [[ $REPLY =~ $1 ]] + then + declare -a buf + while read -r && [[ ! $REPLY =~ $2 ]] + do + buf+=("$REPLY") + done + for line in "${buf[@]}" + do + printf "%s\n" "$line" + done | sort -k $3,$3 # To use case-insensitive sort, add `-f` option to `sort` command + unset buf + printf "%s\n" "$REPLY" + fi + done +} + +cat $PROJECT_FILE_PATH | \ +sort_range "files = \(" "\);" 3 | \ +# This sorts the project structure +sort_range "children = \(" "\);" 3 | \ +cat > $TEMP_FILE_PATH + + +if (( $(diff -u "$TEMP_FILE_PATH" "$PROJECT_FILE_PATH" | wc -c) > 0 )) +then + mv -f "$TEMP_FILE_PATH" "$PROJECT_FILE_PATH" +else + rm "$TEMP_FILE_PATH" +fi + +exit 0 diff --git a/19-appendix-c-uikit/HippoAnalytics/HippoAnalytics.h b/19-appendix-c-uikit/HippoAnalytics/HippoAnalytics.h new file mode 100644 index 0000000..3f89392 --- /dev/null +++ b/19-appendix-c-uikit/HippoAnalytics/HippoAnalytics.h @@ -0,0 +1,18 @@ +// +// HippoAnalytics.h +// HippoAnalytics +// +// Created by Gio on 10/1/21. +// + +#import + +//! Project version number for HippoAnalytics. +FOUNDATION_EXPORT double HippoAnalyticsVersionNumber; + +//! Project version string for HippoAnalytics. +FOUNDATION_EXPORT const unsigned char HippoAnalyticsVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/19-appendix-c-uikit/HippoAnalytics/HippoAnalyticsClient.swift b/19-appendix-c-uikit/HippoAnalytics/HippoAnalyticsClient.swift new file mode 100644 index 0000000..7fa0672 --- /dev/null +++ b/19-appendix-c-uikit/HippoAnalytics/HippoAnalyticsClient.swift @@ -0,0 +1,12 @@ +public class HippoAnalyticsClient { + + public init(apiKey: String) {} + + public func logEvent(named name: String, properties: [String: Any]? = .none) { + if let properties = properties { + print("🦛 HippoAnalytics: Logged event named '\(name)' with properties '\(properties)'") + } else { + print("🦛 HippoAnalytics: Logged event named '\(name)'") + } + } +} diff --git a/19-appendix-c-uikit/HippoAnalytics/Info.plist b/19-appendix-c-uikit/HippoAnalytics/Info.plist new file mode 100644 index 0000000..9bcb244 --- /dev/null +++ b/19-appendix-c-uikit/HippoAnalytics/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/19-appendix-c-uikit/HippoPayments/HippoPayments.h b/19-appendix-c-uikit/HippoPayments/HippoPayments.h new file mode 100644 index 0000000..cd336ab --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/HippoPayments.h @@ -0,0 +1,9 @@ +#import + +//! Project version number for HippoPayments. +FOUNDATION_EXPORT double HippoPaymentsVersionNumber; + +//! Project version string for HippoPayments. +FOUNDATION_EXPORT const unsigned char HippoPaymentsVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import diff --git a/19-appendix-c-uikit/HippoPayments/HippoPaymentsConfirmationViewController.swift b/19-appendix-c-uikit/HippoPayments/HippoPaymentsConfirmationViewController.swift new file mode 100644 index 0000000..cdafb45 --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/HippoPaymentsConfirmationViewController.swift @@ -0,0 +1,50 @@ +import UIKit + +class HippoPaymentsConfirmationViewController: UIViewController { + + let dismissButton = UIButton() + let textLabel = UILabel() + let container = UIStackView() + + var onDismiss: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + isModalInPresentation = true + + container.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(container) + + view.addConstraints( + [.topMargin, .leftMargin, .bottomMargin, .rightMargin].map { + NSLayoutConstraint( + item: container, + attribute: $0, + relatedBy: .equal, + toItem: view, + attribute: $0, + multiplier: 1, + constant: 0 + ) + } + ) + + container.addArrangedSubview(textLabel) + container.addArrangedSubview(dismissButton) + container.axis = .vertical + + textLabel.text = "Your payment was successful\n\nPowered by HippoPayments 🦛" + textLabel.numberOfLines = 0 + textLabel.textAlignment = .center + dismissButton.setTitle("Dismiss", for: .normal) + dismissButton.setTitleColor(.systemBlue, for: .normal) + dismissButton.addTarget(self, action: #selector(dismissButtonTouched), for: .primaryActionTriggered) + } + + @objc + func dismissButtonTouched() { + viewControllerPresentationSource.dismiss(animated: true, completion: onDismiss) + } +} diff --git a/19-appendix-c-uikit/HippoPayments/HippoPaymentsError.swift b/19-appendix-c-uikit/HippoPayments/HippoPaymentsError.swift new file mode 100644 index 0000000..66629c8 --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/HippoPaymentsError.swift @@ -0,0 +1,3 @@ +public enum HippoPaymentsError: Error { + case genericError +} diff --git a/19-appendix-c-uikit/HippoPayments/HippoPaymentsProcessor.swift b/19-appendix-c-uikit/HippoPayments/HippoPaymentsProcessor.swift new file mode 100644 index 0000000..93ad058 --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/HippoPaymentsProcessor.swift @@ -0,0 +1,21 @@ +import UIKit + +public class HippoPaymentsProcessor { + + private let apiKey: String + + public init(apiKey: String) { + self.apiKey = apiKey + } + + public func processPayment( + payload: [String: Any], + onSuccess: @escaping () -> Void, + onFailure: @escaping (HippoPaymentsError) -> Void + ) { + let vc = HippoPaymentsConfirmationViewController() + vc.onDismiss = onSuccess + UIApplication.shared.windows.first?.rootViewController? + .viewControllerPresentationSource.present(vc, animated: true, completion: .none) + } +} diff --git a/19-appendix-c-uikit/HippoPayments/Info.plist b/19-appendix-c-uikit/HippoPayments/Info.plist new file mode 100644 index 0000000..9bcb244 --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/19-appendix-c-uikit/HippoPayments/UIViewController+Presentation.swift b/19-appendix-c-uikit/HippoPayments/UIViewController+Presentation.swift new file mode 100644 index 0000000..dda9535 --- /dev/null +++ b/19-appendix-c-uikit/HippoPayments/UIViewController+Presentation.swift @@ -0,0 +1,11 @@ +import UIKit + +extension UIViewController { + + /// Travels the `presentedViewController` hierarchy backwards till it finds the topmost one. + var viewControllerPresentationSource: UIViewController { + guard let presentedViewController = self.presentedViewController else { return self } + + return presentedViewController.viewControllerPresentationSource + } +}