diff --git a/App/Course App.xcodeproj/project.pbxproj b/App/Course App.xcodeproj/project.pbxproj index 23f954d..0281dfd 100644 --- a/App/Course App.xcodeproj/project.pbxproj +++ b/App/Course App.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ A81F21FB2BDC1DB900A8A301 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A81F21FA2BDC1DB900A8A301 /* Preview Assets.xcassets */; }; A845D9462BF365A60039AA1D /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845D9442BF365A60039AA1D /* HomeViewController.swift */; }; A845D94A2BF36F780039AA1D /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A845D9492BF36F780039AA1D /* Colors.xcassets */; }; - A845D94C2BF3711D0039AA1D /* MockDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845D94B2BF3711D0039AA1D /* MockDataProvider.swift */; }; + A845D94C2BF3711D0039AA1D /* DataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845D94B2BF3711D0039AA1D /* DataProvider.swift */; }; A845D9532BF377250039AA1D /* ReusableIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845D9522BF377250039AA1D /* ReusableIdentifier.swift */; }; A845D9552BF377A70039AA1D /* UICollectionView+ReusableIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845D9542BF377A70039AA1D /* UICollectionView+ReusableIdentifier.swift */; }; A8830C1E2BED0315005CAFEA /* Course_App_UnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8830C1D2BED0315005CAFEA /* Course_App_UnitTests.swift */; }; @@ -66,6 +66,23 @@ F1076BF92C0DDB2800653A6E /* ProfileNavigationCoordinatorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1076BF82C0DDB2800653A6E /* ProfileNavigationCoordinatorEvent.swift */; }; F1076BFB2C0DDCBD00653A6E /* MainTabBarCoordinatorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1076BFA2C0DDCBD00653A6E /* MainTabBarCoordinatorEvent.swift */; }; F1076BFD2C0F0B8500653A6E /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1076BFC2C0F0B8500653A6E /* CustomError.swift */; }; + F13088E22C11F05300FA83EC /* APIManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088E12C11F05300FA83EC /* APIManaging.swift */; }; + F13088E42C11F08000FA83EC /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088E32C11F08000FA83EC /* APIManager.swift */; }; + F13088E72C11F32900FA83EC /* JokeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088E62C11F32900FA83EC /* JokeResponse.swift */; }; + F13088E92C11F58900FA83EC /* JokesRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088E82C11F58900FA83EC /* JokesRouter.swift */; }; + F13088EB2C130A1500FA83EC /* HTTPResponseStatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088EA2C130A1500FA83EC /* HTTPResponseStatusCode.swift */; }; + F13088F02C14A6F600FA83EC /* JokeServicing.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088EF2C14A6F600FA83EC /* JokeServicing.swift */; }; + F13088F22C14A79600FA83EC /* JokeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088F12C14A79600FA83EC /* JokeService.swift */; }; + F13088F62C16EBD400FA83EC /* OnFirstAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088F52C16EBD400FA83EC /* OnFirstAppear.swift */; }; + F13088F82C16EC0F00FA83EC /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088F72C16EC0F00FA83EC /* Action.swift */; }; + F13088FA2C17081000FA83EC /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088F92C17081000FA83EC /* Credentials.swift */; }; + F13088FD2C1708AB00FA83EC /* FirebaseAuthManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088FC2C1708AB00FA83EC /* FirebaseAuthManaging.swift */; }; + F13088FF2C1708FC00FA83EC /* FirebaseAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13088FE2C1708FC00FA83EC /* FirebaseAuthManager.swift */; }; + F13089022C17146600FA83EC /* StorageManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13089012C17146600FA83EC /* StorageManaging.swift */; }; + F13089042C1714AF00FA83EC /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13089032C1714AF00FA83EC /* StorageManager.swift */; }; + F130890C2C19B69E00FA83EC /* HomeViewEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F130890B2C19B69E00FA83EC /* HomeViewEvent.swift */; }; + F130890E2C19B78700FA83EC /* SwipingViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F130890D2C19B78700FA83EC /* SwipingViewFactory.swift */; }; + F13089102C19B81F00FA83EC /* HomeNavigationCoordinatorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F130890F2C19B81F00FA83EC /* HomeNavigationCoordinatorEvent.swift */; }; F18F1A792C107591005364B7 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = F18F1A782C107591005364B7 /* KeychainAccess */; }; F18F1A7B2C1075C7005364B7 /* FirebaseFirestoreCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = F18F1A7A2C1075C7005364B7 /* FirebaseFirestoreCombine-Community */; }; F18F1A7D2C1075C7005364B7 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F18F1A7C2C1075C7005364B7 /* FirebaseFirestoreSwift */; }; @@ -74,6 +91,14 @@ F18F1A872C1076EA005364B7 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A862C1076EA005364B7 /* KeychainManager.swift */; }; F18F1A892C107725005364B7 /* KeychainServicing.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A882C107725005364B7 /* KeychainServicing.swift */; }; F18F1A8B2C10774C005364B7 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A8A2C10774C005364B7 /* KeychainService.swift */; }; + F18F1A8D2C107AFE005364B7 /* OnboardingCoordinatorPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A8C2C107AFE005364B7 /* OnboardingCoordinatorPresenting.swift */; }; + F18F1A8F2C107C70005364B7 /* CancellablesContaining.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A8E2C107C70005364B7 /* CancellablesContaining.swift */; }; + F18F1A922C108CCB005364B7 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A912C108CCB005364B7 /* Endpoint.swift */; }; + F18F1A952C11B3DB005364B7 /* NetworkingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A942C11B3DB005364B7 /* NetworkingError.swift */; }; + F18F1A972C11B3EB005364B7 /* HTTPHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A962C11B3EB005364B7 /* HTTPHeader.swift */; }; + F18F1A992C11B400005364B7 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A982C11B400005364B7 /* HTTPMethod.swift */; }; + F18F1A9B2C11B413005364B7 /* Endpoint+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A9A2C11B413005364B7 /* Endpoint+Convenience.swift */; }; + F18F1A9E2C11B502005364B7 /* ImagesRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18F1A9D2C11B502005364B7 /* ImagesRouter.swift */; }; F190AB292BFDDBA5001AF32D /* SelectableButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F190AB282BFDDBA5001AF32D /* SelectableButtonStyle.swift */; }; F190AB2E2BFDDDA1001AF32D /* BorderedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F190AB2D2BFDDDA1001AF32D /* BorderedModifier.swift */; }; F190AB302BFDDEB7001AF32D /* Image+ResizableBordered.swift in Sources */ = {isa = PBXBuildFile; fileRef = F190AB2F2BFDDEB7001AF32D /* Image+ResizableBordered.swift */; }; @@ -106,7 +131,7 @@ A81F21FA2BDC1DB900A8A301 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; A845D9442BF365A60039AA1D /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; A845D9492BF36F780039AA1D /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; - A845D94B2BF3711D0039AA1D /* MockDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataProvider.swift; sourceTree = ""; }; + A845D94B2BF3711D0039AA1D /* DataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvider.swift; sourceTree = ""; }; A845D9522BF377250039AA1D /* ReusableIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableIdentifier.swift; sourceTree = ""; }; A845D9542BF377A70039AA1D /* UICollectionView+ReusableIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableIdentifier.swift"; sourceTree = ""; }; A8830C1B2BED0315005CAFEA /* Course App UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Course App UnitTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -163,11 +188,36 @@ F1076BF82C0DDB2800653A6E /* ProfileNavigationCoordinatorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNavigationCoordinatorEvent.swift; sourceTree = ""; }; F1076BFA2C0DDCBD00653A6E /* MainTabBarCoordinatorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarCoordinatorEvent.swift; sourceTree = ""; }; F1076BFC2C0F0B8500653A6E /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; + F13088E12C11F05300FA83EC /* APIManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIManaging.swift; sourceTree = ""; }; + F13088E32C11F08000FA83EC /* APIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; + F13088E62C11F32900FA83EC /* JokeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JokeResponse.swift; sourceTree = ""; }; + F13088E82C11F58900FA83EC /* JokesRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JokesRouter.swift; sourceTree = ""; }; + F13088EA2C130A1500FA83EC /* HTTPResponseStatusCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseStatusCode.swift; sourceTree = ""; }; + F13088EF2C14A6F600FA83EC /* JokeServicing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JokeServicing.swift; sourceTree = ""; }; + F13088F12C14A79600FA83EC /* JokeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JokeService.swift; sourceTree = ""; }; + F13088F52C16EBD400FA83EC /* OnFirstAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppear.swift; sourceTree = ""; }; + F13088F72C16EC0F00FA83EC /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + F13088F92C17081000FA83EC /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; + F13088FC2C1708AB00FA83EC /* FirebaseAuthManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAuthManaging.swift; sourceTree = ""; }; + F13088FE2C1708FC00FA83EC /* FirebaseAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAuthManager.swift; sourceTree = ""; }; + F13089012C17146600FA83EC /* StorageManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManaging.swift; sourceTree = ""; }; + F13089032C1714AF00FA83EC /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; + F130890B2C19B69E00FA83EC /* HomeViewEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewEvent.swift; sourceTree = ""; }; + F130890D2C19B78700FA83EC /* SwipingViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipingViewFactory.swift; sourceTree = ""; }; + F130890F2C19B81F00FA83EC /* HomeNavigationCoordinatorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeNavigationCoordinatorEvent.swift; sourceTree = ""; }; F18F1A802C107656005364B7 /* KeychainManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManaging.swift; sourceTree = ""; }; F18F1A842C1076A8005364B7 /* KeychainManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManagerError.swift; sourceTree = ""; }; F18F1A862C1076EA005364B7 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; F18F1A882C107725005364B7 /* KeychainServicing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServicing.swift; sourceTree = ""; }; F18F1A8A2C10774C005364B7 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + F18F1A8C2C107AFE005364B7 /* OnboardingCoordinatorPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinatorPresenting.swift; sourceTree = ""; }; + F18F1A8E2C107C70005364B7 /* CancellablesContaining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellablesContaining.swift; sourceTree = ""; }; + F18F1A912C108CCB005364B7 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; + F18F1A942C11B3DB005364B7 /* NetworkingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingError.swift; sourceTree = ""; }; + F18F1A962C11B3EB005364B7 /* HTTPHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeader.swift; sourceTree = ""; }; + F18F1A982C11B400005364B7 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; + F18F1A9A2C11B413005364B7 /* Endpoint+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Endpoint+Convenience.swift"; sourceTree = ""; }; + F18F1A9D2C11B502005364B7 /* ImagesRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRouter.swift; sourceTree = ""; }; F190AB282BFDDBA5001AF32D /* SelectableButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableButtonStyle.swift; sourceTree = ""; }; F190AB2D2BFDDDA1001AF32D /* BorderedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderedModifier.swift; sourceTree = ""; }; F190AB2F2BFDDEB7001AF32D /* Image+ResizableBordered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+ResizableBordered.swift"; sourceTree = ""; }; @@ -230,10 +280,11 @@ A81F21F22BDC1DB700A8A301 /* Course App */ = { isa = PBXGroup; children = ( - A8830C3E2BED0E80005CAFEA /* Course-App-Info.plist */, F1076B682C0101C300653A6E /* App */, A845D9502BF376860039AA1D /* Common */, + A8830C3E2BED0E80005CAFEA /* Course-App-Info.plist */, A845D9482BF36F070039AA1D /* DataAPI */, + F18F1A902C108C62005364B7 /* Networking */, A81F21F92BDC1DB900A8A301 /* Preview Content */, F1076B692C01023700653A6E /* Scenes */, F190AB262BFDDB68001AF32D /* SharedUI */, @@ -289,7 +340,7 @@ A845D9482BF36F070039AA1D /* DataAPI */ = { isa = PBXGroup; children = ( - A845D94B2BF3711D0039AA1D /* MockDataProvider.swift */, + A845D94B2BF3711D0039AA1D /* DataProvider.swift */, ); path = DataAPI; sourceTree = ""; @@ -297,11 +348,11 @@ A845D9502BF376860039AA1D /* Common */ = { isa = PBXGroup; children = ( - F18F1A7F2C10763C005364B7 /* Services */, - F18F1A7E2C107639005364B7 /* Managers */, F1076B6E2C0102B000653A6E /* Extensions */, + F18F1A7E2C107639005364B7 /* Managers */, F1076BAE2C08FB4B00653A6E /* Navigation */, F1076B6D2C0102A700653A6E /* Protocols */, + F18F1A7F2C10763C005364B7 /* Services */, F1076BBA2C09025600653A6E /* Types */, ); path = Common; @@ -397,6 +448,7 @@ children = ( F1076BC32C090BF000653A6E /* Navigation */, A845D9442BF365A60039AA1D /* HomeViewController.swift */, + F130890B2C19B69E00FA83EC /* HomeViewEvent.swift */, ); path = Home; sourceTree = ""; @@ -415,6 +467,7 @@ children = ( A845D9522BF377250039AA1D /* ReusableIdentifier.swift */, F1076BDA2C0DA2EA00653A6E /* EventEmitting.swift */, + F18F1A8E2C107C70005364B7 /* CancellablesContaining.swift */, ); path = Protocols; sourceTree = ""; @@ -454,6 +507,7 @@ F1076BBC2C09027100653A6E /* CoordinatorView.swift */, F1076BD82C0D9F5400653A6E /* Deeplink.swift */, F1076BFC2C0F0B8500653A6E /* CustomError.swift */, + F13088F72C16EC0F00FA83EC /* Action.swift */, ); path = Types; sourceTree = ""; @@ -462,6 +516,7 @@ isa = PBXGroup; children = ( F1076BC12C0908A400653A6E /* SwipingViewNavigationCoordinator.swift */, + F130890D2C19B78700FA83EC /* SwipingViewFactory.swift */, ); path = Navigation; sourceTree = ""; @@ -470,6 +525,7 @@ isa = PBXGroup; children = ( F1076BC42C090C0600653A6E /* HomeNavigationCoordinator.swift */, + F130890F2C19B81F00FA83EC /* HomeNavigationCoordinatorEvent.swift */, ); path = Navigation; sourceTree = ""; @@ -488,6 +544,7 @@ F1076BC72C0B472C00653A6E /* Navigation */ = { isa = PBXGroup; children = ( + F18F1A8C2C107AFE005364B7 /* OnboardingCoordinatorPresenting.swift */, F1076BCC2C0B475A00653A6E /* OnboardingNavigationCoordinator.swift */, F1076BDE2C0DA48A00653A6E /* OnboardingNavigationCoordinatorEvent.swift */, ); @@ -532,6 +589,51 @@ path = Navigation; sourceTree = ""; }; + F13088E52C11F31800FA83EC /* Models */ = { + isa = PBXGroup; + children = ( + F13088E62C11F32900FA83EC /* JokeResponse.swift */, + F13088F92C17081000FA83EC /* Credentials.swift */, + ); + path = Models; + sourceTree = ""; + }; + F13088EC2C14A6C700FA83EC /* Managers */ = { + isa = PBXGroup; + children = ( + F13088E32C11F08000FA83EC /* APIManager.swift */, + F13088E12C11F05300FA83EC /* APIManaging.swift */, + ); + path = Managers; + sourceTree = ""; + }; + F13088ED2C14A6CC00FA83EC /* Services */ = { + isa = PBXGroup; + children = ( + F13088EF2C14A6F600FA83EC /* JokeServicing.swift */, + F13088F12C14A79600FA83EC /* JokeService.swift */, + ); + path = Services; + sourceTree = ""; + }; + F13088FB2C17084E00FA83EC /* Authentication */ = { + isa = PBXGroup; + children = ( + F13088FC2C1708AB00FA83EC /* FirebaseAuthManaging.swift */, + F13088FE2C1708FC00FA83EC /* FirebaseAuthManager.swift */, + ); + path = Authentication; + sourceTree = ""; + }; + F13089002C17145100FA83EC /* Storage */ = { + isa = PBXGroup; + children = ( + F13089012C17146600FA83EC /* StorageManaging.swift */, + F13089032C1714AF00FA83EC /* StorageManager.swift */, + ); + path = Storage; + sourceTree = ""; + }; F18F1A7E2C107639005364B7 /* Managers */ = { isa = PBXGroup; children = ( @@ -551,6 +653,42 @@ path = Services; sourceTree = ""; }; + F18F1A902C108C62005364B7 /* Networking */ = { + isa = PBXGroup; + children = ( + F13089002C17145100FA83EC /* Storage */, + F13088FB2C17084E00FA83EC /* Authentication */, + F18F1A912C108CCB005364B7 /* Endpoint.swift */, + F18F1A9A2C11B413005364B7 /* Endpoint+Convenience.swift */, + F18F1A9C2C11B4F1005364B7 /* Endpoints */, + F13088EC2C14A6C700FA83EC /* Managers */, + F18F1A932C11B3BC005364B7 /* Misc */, + F13088E52C11F31800FA83EC /* Models */, + F13088ED2C14A6CC00FA83EC /* Services */, + ); + path = Networking; + sourceTree = ""; + }; + F18F1A932C11B3BC005364B7 /* Misc */ = { + isa = PBXGroup; + children = ( + F18F1A942C11B3DB005364B7 /* NetworkingError.swift */, + F18F1A962C11B3EB005364B7 /* HTTPHeader.swift */, + F18F1A982C11B400005364B7 /* HTTPMethod.swift */, + F13088EA2C130A1500FA83EC /* HTTPResponseStatusCode.swift */, + ); + path = Misc; + sourceTree = ""; + }; + F18F1A9C2C11B4F1005364B7 /* Endpoints */ = { + isa = PBXGroup; + children = ( + F18F1A9D2C11B502005364B7 /* ImagesRouter.swift */, + F13088E82C11F58900FA83EC /* JokesRouter.swift */, + ); + path = Endpoints; + sourceTree = ""; + }; F190AB262BFDDB68001AF32D /* SharedUI */ = { isa = PBXGroup; children = ( @@ -578,6 +716,7 @@ F190AB2D2BFDDDA1001AF32D /* BorderedModifier.swift */, F190AB2F2BFDDEB7001AF32D /* Image+ResizableBordered.swift */, F1076B522C00FE1000653A6E /* TextStyle.swift */, + F13088F52C16EBD400FA83EC /* OnFirstAppear.swift */, ); path = ViewModifiers; sourceTree = ""; @@ -731,6 +870,7 @@ buildActionMask = 2147483647; files = ( F1076BF12C0DC1DA00653A6E /* SignInViewEvent.swift in Sources */, + F13088F02C14A6F600FA83EC /* JokeServicing.swift in Sources */, F1076BB42C08FC9800653A6E /* TabBarControllerCoordinator.swift in Sources */, F1076BEF2C0DBF9A00653A6E /* SignInView.swift in Sources */, F1076BC22C0908A400653A6E /* SwipingViewNavigationCoordinator.swift in Sources */, @@ -738,9 +878,14 @@ F190AB362BFDE300001AF32D /* SwipingCard.swift in Sources */, F1076BDD2C0DA40000653A6E /* OnboardingViewEvent.swift in Sources */, F1076BF92C0DDB2800653A6E /* ProfileNavigationCoordinatorEvent.swift in Sources */, + F13088F22C14A79600FA83EC /* JokeService.swift in Sources */, F1076B582C00FF2400653A6E /* FontSize.swift in Sources */, F1076B532C00FE1000653A6E /* TextStyle.swift in Sources */, + F13088EB2C130A1500FA83EC /* HTTPResponseStatusCode.swift in Sources */, F1076B632C00FF8500653A6E /* UIFont+AppFonts.swift in Sources */, + F18F1A922C108CCB005364B7 /* Endpoint.swift in Sources */, + F13088E42C11F08000FA83EC /* APIManager.swift in Sources */, + F18F1A992C11B400005364B7 /* HTTPMethod.swift in Sources */, A845D9532BF377250039AA1D /* ReusableIdentifier.swift in Sources */, F18F1A892C107725005364B7 /* KeychainServicing.swift in Sources */, F1076BCF2C0B552B00653A6E /* NavigationButtonStyle.swift in Sources */, @@ -749,28 +894,42 @@ F1076B562C00FE4A00653A6E /* TextType.swift in Sources */, F1076B5C2C00FF4100653A6E /* PaddingSize.swift in Sources */, F1076BE12C0DAB1C00653A6E /* CustomNavigationControllerEvent.swift in Sources */, + F13089022C17146600FA83EC /* StorageManaging.swift in Sources */, F1076BD32C0B5CAA00653A6E /* OnboardingPage.swift in Sources */, F1076BB02C08FB5500653A6E /* Coordinator.swift in Sources */, F1076BF32C0DC55200653A6E /* SignInNavigationCoordinatorEvent.swift in Sources */, + F13088E22C11F05300FA83EC /* APIManaging.swift in Sources */, F1076B5E2C00FF5200653A6E /* CornerRadiusSize.swift in Sources */, + F18F1A9B2C11B413005364B7 /* Endpoint+Convenience.swift in Sources */, F1076BCD2C0B475A00653A6E /* OnboardingNavigationCoordinator.swift in Sources */, + F18F1A8D2C107AFE005364B7 /* OnboardingCoordinatorPresenting.swift in Sources */, F18F1A872C1076EA005364B7 /* KeychainManager.swift in Sources */, F1076BBF2C09052900653A6E /* AppCoordinator.swift in Sources */, + F18F1A9E2C11B502005364B7 /* ImagesRouter.swift in Sources */, + F13088FF2C1708FC00FA83EC /* FirebaseAuthManager.swift in Sources */, F1076BCA2C0B474200653A6E /* OnboardingView.swift in Sources */, A8830C402BED0EE0005CAFEA /* BuildConfiguration.swift in Sources */, A845D9552BF377A70039AA1D /* UICollectionView+ReusableIdentifier.swift in Sources */, + F18F1A8F2C107C70005364B7 /* CancellablesContaining.swift in Sources */, F1076BE52C0DB83300653A6E /* ProfileNavigationCoordinator.swift in Sources */, + F13089102C19B81F00FA83EC /* HomeNavigationCoordinatorEvent.swift in Sources */, + F13088E72C11F32900FA83EC /* JokeResponse.swift in Sources */, F190AB292BFDDBA5001AF32D /* SelectableButtonStyle.swift in Sources */, F1076BDF2C0DA48A00653A6E /* OnboardingNavigationCoordinatorEvent.swift in Sources */, F1076BF72C0DDA9400653A6E /* ProfileViewEvent.swift in Sources */, + F13089042C1714AF00FA83EC /* StorageManager.swift in Sources */, F1076BBD2C09027100653A6E /* CoordinatorView.swift in Sources */, F1076B612C00FF7500653A6E /* Font+AppFonts.swift in Sources */, - A845D94C2BF3711D0039AA1D /* MockDataProvider.swift in Sources */, + A845D94C2BF3711D0039AA1D /* DataProvider.swift in Sources */, F190AB342BFDE2D0001AF32D /* SwipingView.swift in Sources */, F18F1A8B2C10774C005364B7 /* KeychainService.swift in Sources */, F1076BDB2C0DA2EA00653A6E /* EventEmitting.swift in Sources */, F1076BFB2C0DDCBD00653A6E /* MainTabBarCoordinatorEvent.swift in Sources */, F1076BB62C08FCFE00653A6E /* NavigationControllerCoordinator.swift in Sources */, + F13088FD2C1708AB00FA83EC /* FirebaseAuthManaging.swift in Sources */, + F130890C2C19B69E00FA83EC /* HomeViewEvent.swift in Sources */, + F13088E92C11F58900FA83EC /* JokesRouter.swift in Sources */, + F13088F82C16EC0F00FA83EC /* Action.swift in Sources */, F1076BC52C090C0600653A6E /* HomeNavigationCoordinator.swift in Sources */, F1076B672C0101A700653A6E /* CustomNavigationController.swift in Sources */, F1076BFD2C0F0B8500653A6E /* CustomError.swift in Sources */, @@ -778,11 +937,16 @@ F190AB2E2BFDDDA1001AF32D /* BorderedModifier.swift in Sources */, F18F1A812C107656005364B7 /* KeychainManaging.swift in Sources */, F1076B5A2C00FF2F00653A6E /* FontType.swift in Sources */, + F130890E2C19B78700FA83EC /* SwipingViewFactory.swift in Sources */, F18F1A852C1076A8005364B7 /* KeychainManagerError.swift in Sources */, + F18F1A952C11B3DB005364B7 /* NetworkingError.swift in Sources */, F190AB382BFDE372001AF32D /* ScratchView.swift in Sources */, + F18F1A972C11B3EB005364B7 /* HTTPHeader.swift in Sources */, F1076BE92C0DB91700653A6E /* ProfileView.swift in Sources */, F1076BB92C08FDA500653A6E /* MainTabBarCoordinator.swift in Sources */, F190AB302BFDDEB7001AF32D /* Image+ResizableBordered.swift in Sources */, + F13088F62C16EBD400FA83EC /* OnFirstAppear.swift in Sources */, + F13088FA2C17081000FA83EC /* Credentials.swift in Sources */, F1076B652C01016B00653A6E /* UINavigationController+NavBarUI.swift in Sources */, A81F21F42BDC1DB700A8A301 /* Course_AppApp.swift in Sources */, F1076BD12C0B560E00653A6E /* ButtonStyle+CustomButtonStyle.swift in Sources */, diff --git a/App/Course App/App/AppCoordinator.swift b/App/Course App/App/AppCoordinator.swift index 19adce8..01a6542 100644 --- a/App/Course App/App/AppCoordinator.swift +++ b/App/Course App/App/AppCoordinator.swift @@ -8,7 +8,7 @@ import Combine import UIKit -final class AppCoordinator: ObservableObject, ViewControllerCoordinator { +final class AppCoordinator: ObservableObject, ViewControllerCoordinator, CancellablesContaining { // MARK: Private properties private(set) lazy var rootViewController: UIViewController = { if isSignedIn { @@ -17,9 +17,9 @@ final class AppCoordinator: ObservableObject, ViewControllerCoordinator { makeSignInFlow().rootViewController } }() - private var anyCancellables = Set() // MARK: Public properties + var cancellables = Set() var childCoordinators = [Coordinator]() @Published var isSignedIn = false } @@ -39,7 +39,7 @@ private extension AppCoordinator { mainTabBarCoordinator.eventPublisher.sink { [weak self] event in self?.handle(event) } - .store(in: &anyCancellables) + .store(in: &cancellables) return mainTabBarCoordinator } @@ -49,7 +49,7 @@ private extension AppCoordinator { signInCoordinator.eventPublisher.sink { [weak self] event in self?.handle(event) } - .store(in: &anyCancellables) + .store(in: &cancellables) return signInCoordinator } } @@ -57,7 +57,6 @@ private extension AppCoordinator { // MARK: - Setup UI private extension AppCoordinator { func setupAppUI() { - UITabBar.appearance().backgroundColor = .brown UITabBar.appearance().tintColor = .white UITabBarItem.appearance().setTitleTextAttributes( [ @@ -65,6 +64,11 @@ private extension AppCoordinator { ], for: .normal ) + let tabBarAppearance = UITabBarAppearance() + tabBarAppearance.configureWithDefaultBackground() + tabBarAppearance.backgroundColor = .brown + UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance + UITabBar.appearance().standardAppearance = tabBarAppearance UINavigationBar.appearance().tintColor = .white } diff --git a/App/Course App/App/BuildConfiguration.swift b/App/Course App/App/BuildConfiguration.swift index a01a279..e9dfd9a 100644 --- a/App/Course App/App/BuildConfiguration.swift +++ b/App/Course App/App/BuildConfiguration.swift @@ -13,8 +13,8 @@ struct BuildConfiguration: Decodable { case jokesBaseURL = "API_JOKES_BASE_URL" } - let jokesBaseURL: String - let imagesBaseURL: String + let jokesBaseURL: URL + let imagesBaseURL: URL } extension BuildConfiguration { diff --git a/App/Course App/App/Course_AppApp.swift b/App/Course App/App/Course_AppApp.swift index a243d0c..f22765c 100644 --- a/App/Course App/App/Course_AppApp.swift +++ b/App/Course App/App/Course_AppApp.swift @@ -9,7 +9,7 @@ import FirebaseCore import os import SwiftUI -class AppDelegate: NSObject, UIApplicationDelegate { +final class AppDelegate: NSObject, UIApplicationDelegate { weak var deeplinkHandler: DeeplinkHandling? func application( diff --git a/App/Course App/Common/Protocols/CancellablesContaining.swift b/App/Course App/Common/Protocols/CancellablesContaining.swift new file mode 100644 index 0000000..a0afe52 --- /dev/null +++ b/App/Course App/Common/Protocols/CancellablesContaining.swift @@ -0,0 +1,12 @@ +// +// CancellablesContaining.swift +// Course App +// +// Created by Tomáš Duchoslav on 05.06.2024. +// + +import Combine + +protocol CancellablesContaining: AnyObject { + var cancellables: Set { get set } +} diff --git a/App/Course App/Common/Services/KeychainService.swift b/App/Course App/Common/Services/KeychainService.swift index aba4357..4b0c6fb 100644 --- a/App/Course App/Common/Services/KeychainService.swift +++ b/App/Course App/Common/Services/KeychainService.swift @@ -21,4 +21,12 @@ final class KeychainService: KeychainServicing { func storeAuthData(authData: String) throws { try keychainManager.store(key: KeychainKey.authData.rawValue, value: authData) } + + func fetchAuthData() throws -> String { + try keychainManager.fetch(key: KeychainKey.authData.rawValue) + } + + func removeAuthData() throws { + try keychainManager.remove(key: KeychainKey.authData.rawValue) + } } diff --git a/App/Course App/Common/Types/Action.swift b/App/Course App/Common/Types/Action.swift new file mode 100644 index 0000000..d316e2a --- /dev/null +++ b/App/Course App/Common/Types/Action.swift @@ -0,0 +1,11 @@ +// +// Action.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import Foundation + +typealias VoidAction = () -> Void +typealias Action = (T) -> Void diff --git a/App/Course App/DataAPI/DataProvider.swift b/App/Course App/DataAPI/DataProvider.swift new file mode 100644 index 0000000..54ddbfa --- /dev/null +++ b/App/Course App/DataAPI/DataProvider.swift @@ -0,0 +1,38 @@ +// +// MockDataProvider.swift +// Course App +// +// Created by Tomáš Duchoslav on 14.05.2024. +// + +import UIKit + +struct SectionData: Identifiable, Hashable { + let id = UUID() + let title: String + var jokes: [Joke] + + init(title: String, jokes: [JokeResponse]) { + self.title = title + self.jokes = jokes.map { Joke(jokeResponse: $0) } + } +} + +struct Joke: Identifiable, Hashable { + let id = UUID() + let jokeID: String + let text: String + let categories: [String] + var isLiked: Bool? + + init(jokeResponse: JokeResponse, isLiked: Bool? = nil) { + self.jokeID = jokeResponse.id + self.text = jokeResponse.value + self.categories = jokeResponse.categories + self.isLiked = isLiked + } +} + +final class DataProvider: ObservableObject { + @Published var data: [SectionData] = [] +} diff --git a/App/Course App/DataAPI/MockDataProvider.swift b/App/Course App/DataAPI/MockDataProvider.swift deleted file mode 100644 index a46eb84..0000000 --- a/App/Course App/DataAPI/MockDataProvider.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MockDataProvider.swift -// Course App -// -// Created by Tomáš Duchoslav on 14.05.2024. -// - -import UIKit - -let mockImages = [ - UIImage.nature, - UIImage.computer, - UIImage.food -] - -struct SectionData: Identifiable, Hashable { - let id = UUID() - let title: String - var jokes: [Joke] -} - -struct Joke: Identifiable, Hashable { - let id = UUID() - let text: String - let image = mockImages.randomElement() -} - -final class MockDataProvider: ObservableObject { - @Published var data: [SectionData] - - private var localData = [ - SectionData(title: "Celebrations", jokes: [ - Joke(text: "Chuck Norris can make hamburger out of ham."), - Joke(text: "All your base are belong to Chuck Norris"), - Joke(text: "Chuck Norris can hit a barn door with a broad's side.") - ]), - SectionData(title: "Next Section", jokes: [ - Joke(text: "Joke one"), - Joke(text: "joke two"), - Joke(text: "Joke three") - ]) - ] - - init() { - data = localData - // updateData() - } -} - -private extension MockDataProvider { - func updateData() { - let second: UInt64 = 4_000_000_000 - let deadline = DispatchTime(uptimeNanoseconds: second) - DispatchQueue.main.asyncAfter(deadline: deadline, execute: { - if var section = self.localData.first { - section.jokes.remove(at: 1) - self.data = [section] - } - }) - } -} diff --git a/App/Course App/Networking/Authentication/FirebaseAuthManager.swift b/App/Course App/Networking/Authentication/FirebaseAuthManager.swift new file mode 100644 index 0000000..9c1814c --- /dev/null +++ b/App/Course App/Networking/Authentication/FirebaseAuthManager.swift @@ -0,0 +1,39 @@ +// +// FirebaseAuthManager.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import FirebaseAuth +import Foundation + +final class FirebaseAuthManager: FirebaseAuthManaging { + // MARK: Private properties + private let authService = Auth.auth() + private let keychainService: KeychainServicing = KeychainService(keychainManager: KeychainManager()) + + // MARK: Functions + func signUp(_ credentials: Credentials) async throws { + let result = try await authService.createUser(withEmail: credentials.email, password: credentials.password) + + guard let accessToken = try? await result.user.getIDToken() else { + return + } + + try keychainService.storeAuthData(authData: accessToken) + } + + func signIn(_ entity: Credentials) async throws { + let result = try await authService.signIn(withEmail: entity.email, password: entity.password) + guard let accessToken = try? await result.user.getIDToken() else { + return + } + + try keychainService.storeAuthData(authData: accessToken) + } + + func signOut() async throws { + try authService.signOut() + } +} diff --git a/App/Course App/Networking/Authentication/FirebaseAuthManaging.swift b/App/Course App/Networking/Authentication/FirebaseAuthManaging.swift new file mode 100644 index 0000000..b174763 --- /dev/null +++ b/App/Course App/Networking/Authentication/FirebaseAuthManaging.swift @@ -0,0 +1,14 @@ +// +// FirebaseAuthManaging.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import Foundation + +protocol FirebaseAuthManaging { + func signUp(_ credentials: Credentials) async throws + func signIn(_ entity: Credentials) async throws + func signOut() async throws +} diff --git a/App/Course App/Networking/Endpoint+Convenience.swift b/App/Course App/Networking/Endpoint+Convenience.swift new file mode 100644 index 0000000..8819d69 --- /dev/null +++ b/App/Course App/Networking/Endpoint+Convenience.swift @@ -0,0 +1,46 @@ +// +// Endpoint+Convenience.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +extension Endpoint { + var method: HTTPMethod { + .get + } + + var headers: [String: String] { + [:] + } + + var urlParameters: [String: String] { + [:] + } + + func asURLRequest() throws -> URLRequest { + let url = host.appendingPathComponent(path) + + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + throw NetworkingError.invalidUrlComponents + } + + if !urlParameters.isEmpty { + urlComponents.queryItems = urlParameters.map { URLQueryItem(name: $0, value: String(describing: $1)) } + } + + guard let url = urlComponents.url else { + throw NetworkingError.invalidUrlComponents + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.allHTTPHeaderFields = headers + + request.setValue(HTTPHeader.ContentType.json.rawValue, forHTTPHeaderField: HTTPHeader.HeaderField.contentType.rawValue) + + return request + } +} diff --git a/App/Course App/Networking/Endpoint.swift b/App/Course App/Networking/Endpoint.swift new file mode 100644 index 0000000..6693087 --- /dev/null +++ b/App/Course App/Networking/Endpoint.swift @@ -0,0 +1,18 @@ +// +// Endpoint.swift +// Course App +// +// Created by Tomáš Duchoslav on 05.06.2024. +// + +import Foundation + +protocol Endpoint { + var host: URL { get } + var path: String { get } + var method: HTTPMethod { get } + var headers: [String: String] { get } + var urlParameters: [String: String] { get } + + func asURLRequest() throws -> URLRequest +} diff --git a/App/Course App/Networking/Endpoints/ImagesRouter.swift b/App/Course App/Networking/Endpoints/ImagesRouter.swift new file mode 100644 index 0000000..3e06567 --- /dev/null +++ b/App/Course App/Networking/Endpoints/ImagesRouter.swift @@ -0,0 +1,20 @@ +// +// ImagesRouter.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +enum ImagesRouter: Endpoint { + case size300x200 + + var host: URL { + BuildConfiguration.default.imagesBaseURL + } + + var path: String { + "300/200" + } +} diff --git a/App/Course App/Networking/Endpoints/JokesRouter.swift b/App/Course App/Networking/Endpoints/JokesRouter.swift new file mode 100644 index 0000000..0968181 --- /dev/null +++ b/App/Course App/Networking/Endpoints/JokesRouter.swift @@ -0,0 +1,36 @@ +// +// JokesRoutes.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +enum JokesRouter: Endpoint { + case getJokeCategories + case getRandomJoke + case getJokeFor(category: String) + + var host: URL { + BuildConfiguration.default.jokesBaseURL + } + + var path: String { + switch self { + case .getJokeCategories: + "jokes/categories" + case .getRandomJoke, .getJokeFor: + "jokes/random" + } + } + + var urlParameters: [String: String] { + switch self { + case let .getJokeFor(category): + ["category": category] + default: + [String: String]() + } + } +} diff --git a/App/Course App/Networking/Managers/APIManager.swift b/App/Course App/Networking/Managers/APIManager.swift new file mode 100644 index 0000000..49569eb --- /dev/null +++ b/App/Course App/Networking/Managers/APIManager.swift @@ -0,0 +1,65 @@ +// +// APIManager.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation +import os + +final class APIManager: APIManaging { + private let logger = Logger() + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" + return dateFormatter + }() + private lazy var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .formatted(dateFormatter) + return decoder + }() + + func request(_ endpoint: any Endpoint) async throws -> T where T: Decodable { + let data = try await request(endpoint) + + do { + return try decoder.decode(T.self, from: data) + } catch { + logger.info("Decoder failed with \(error)") + throw NetworkingError.decodingFailed(error: error) + } + } + + func request(_ endpoint: Endpoint) async throws -> Data { + let request: URLRequest = try endpoint.asURLRequest() + + logger.info("Request for \"\(request.description)\"") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkingError.noHttpResponse + } + + try checkStatusCode(httpResponse) + + let body = String(decoding: data, as: UTF8.self) + logger.info(""" + Response for \"\(request.description)\" + Status: \(httpResponse.statusCode) + Body: + \(body) + """) + + return data + } + + func checkStatusCode(_ urlResponse: HTTPURLResponse) throws { + guard HTTPResponseStatusCode.successful.range ~= urlResponse.statusCode else { + throw NetworkingError.unacceptableStatusCode + } + } +} diff --git a/App/Course App/Networking/Managers/APIManaging.swift b/App/Course App/Networking/Managers/APIManaging.swift new file mode 100644 index 0000000..03cc178 --- /dev/null +++ b/App/Course App/Networking/Managers/APIManaging.swift @@ -0,0 +1,12 @@ +// +// APIManaging.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +protocol APIManaging { + func request(_ endpoint: Endpoint) async throws -> T +} diff --git a/App/Course App/Networking/Misc/HTTPHeader.swift b/App/Course App/Networking/Misc/HTTPHeader.swift new file mode 100644 index 0000000..4b37294 --- /dev/null +++ b/App/Course App/Networking/Misc/HTTPHeader.swift @@ -0,0 +1,19 @@ +// +// HTTPHeader.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +enum HTTPHeader { + enum HeaderField: String { + case contentType = "Content-Type" + } + + enum ContentType: String { + case json = "application/json" + case text = "text/html;charset=utf-8" + } +} diff --git a/App/Course App/Networking/Misc/HTTPMethod.swift b/App/Course App/Networking/Misc/HTTPMethod.swift new file mode 100644 index 0000000..adde957 --- /dev/null +++ b/App/Course App/Networking/Misc/HTTPMethod.swift @@ -0,0 +1,16 @@ +// +// HTTPMethod.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} diff --git a/App/Course App/Networking/Misc/HTTPResponseStatusCode.swift b/App/Course App/Networking/Misc/HTTPResponseStatusCode.swift new file mode 100644 index 0000000..464d9fb --- /dev/null +++ b/App/Course App/Networking/Misc/HTTPResponseStatusCode.swift @@ -0,0 +1,33 @@ +// +// HTTPResponseStatusCode.swift +// Course App +// +// Created by Tomáš Duchoslav on 07.06.2024. +// + +import Foundation + +// swiftlint:disable no_magic_numbers +enum HTTPResponseStatusCode { + case informational + case successful + case redirection + case clientError + case serverError + + var range: CountableClosedRange { + switch self { + case .informational: + 100...199 + case .successful: + 200...299 + case .redirection: + 300...399 + case .clientError: + 400...499 + case .serverError: + 500...599 + } + } +} +// swiftlint:enable no_magic_numbers diff --git a/App/Course App/Networking/Misc/NetworkingError.swift b/App/Course App/Networking/Misc/NetworkingError.swift new file mode 100644 index 0000000..ce15bbe --- /dev/null +++ b/App/Course App/Networking/Misc/NetworkingError.swift @@ -0,0 +1,15 @@ +// +// NetworkingError.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +enum NetworkingError: Error { + case unacceptableStatusCode + case noHttpResponse + case decodingFailed(error: Error) + case invalidUrlComponents +} diff --git a/App/Course App/Networking/Models/Credentials.swift b/App/Course App/Networking/Models/Credentials.swift new file mode 100644 index 0000000..5bb9767 --- /dev/null +++ b/App/Course App/Networking/Models/Credentials.swift @@ -0,0 +1,13 @@ +// +// Credentials.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import Foundation + +struct Credentials { + let email: String + let password: String +} diff --git a/App/Course App/Networking/Models/JokeResponse.swift b/App/Course App/Networking/Models/JokeResponse.swift new file mode 100644 index 0000000..9c55eba --- /dev/null +++ b/App/Course App/Networking/Models/JokeResponse.swift @@ -0,0 +1,16 @@ +// +// JokeResponse.swift +// Course App +// +// Created by Tomáš Duchoslav on 06.06.2024. +// + +import Foundation + +struct JokeResponse: Codable { + let id: String + let categories: [String] + let createdAt: Date + let url: URL + let value: String +} diff --git a/App/Course App/Networking/Services/JokeService.swift b/App/Course App/Networking/Services/JokeService.swift new file mode 100644 index 0000000..def84f0 --- /dev/null +++ b/App/Course App/Networking/Services/JokeService.swift @@ -0,0 +1,31 @@ +// +// JokeService.swift +// Course App +// +// Created by Tomáš Duchoslav on 08.06.2024. +// + +import Foundation + +final class JokeService: JokeServicing { + let apiManager: APIManaging + + init(apiManager: APIManaging) { + self.apiManager = apiManager + } +} + +// MARK: - Functions +extension JokeService { + func loadRandomJoke() async throws -> JokeResponse { + try await apiManager.request(JokesRouter.getRandomJoke) + } + + func loadCategories() async throws -> [String] { + try await apiManager.request(JokesRouter.getJokeCategories) + } + + func loadJokeForCategory(_ category: String) async throws -> JokeResponse { + try await apiManager.request(JokesRouter.getJokeFor(category: category)) + } +} diff --git a/App/Course App/Networking/Services/JokeServicing.swift b/App/Course App/Networking/Services/JokeServicing.swift new file mode 100644 index 0000000..40f3e68 --- /dev/null +++ b/App/Course App/Networking/Services/JokeServicing.swift @@ -0,0 +1,16 @@ +// +// JokeServicing.swift +// Course App +// +// Created by Tomáš Duchoslav on 08.06.2024. +// + +import Foundation + +protocol JokeServicing { + var apiManager: APIManaging { get } + + func loadCategories() async throws -> [String] + func loadRandomJoke() async throws -> JokeResponse + func loadJokeForCategory(_ category: String) async throws -> JokeResponse +} diff --git a/App/Course App/Networking/Storage/StorageManager.swift b/App/Course App/Networking/Storage/StorageManager.swift new file mode 100644 index 0000000..9776e79 --- /dev/null +++ b/App/Course App/Networking/Storage/StorageManager.swift @@ -0,0 +1,42 @@ +// +// StorageManager.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import FirebaseFirestore +import Foundation +import os + + +final class StorageManager: StorageManaging { + // MARK: Private properties + private let database = Firestore.firestore() + private let logger = Logger() + + // MARK: Functions + func storeLike(jokeId: String, liked: Bool) async throws { + do { + try await database.collection("jokesLikes").document(jokeId).setData([ + "liked": liked + ]) + logger.info("Document successfully written!") + } catch { + logger.info("Error writing document: \(error)") + } + } + + func liked(jokeId: String) async throws -> Bool { + let docRef = database.collection("jokesLikes").document(jokeId) + do { + let document = try await docRef.getDocument() + if let liked = document.data()?["liked"] as? Bool { + return liked + } + } catch { + logger.info("Error reading document: \(error)") + } + return false + } +} diff --git a/App/Course App/Networking/Storage/StorageManaging.swift b/App/Course App/Networking/Storage/StorageManaging.swift new file mode 100644 index 0000000..9245522 --- /dev/null +++ b/App/Course App/Networking/Storage/StorageManaging.swift @@ -0,0 +1,13 @@ +// +// StorageManaging.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import FirebaseFirestore +import Foundation + +protocol StorageManaging { + func storeLike(jokeId: String, liked: Bool) async throws +} diff --git a/App/Course App/Scenes/Home/HomeViewController.swift b/App/Course App/Scenes/Home/HomeViewController.swift index 244dd16..3a60092 100644 --- a/App/Course App/Scenes/Home/HomeViewController.swift +++ b/App/Course App/Scenes/Home/HomeViewController.swift @@ -10,47 +10,118 @@ import os import SwiftUI import UIKit -// HELPER -struct HomeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> HomeViewController { - HomeViewController() - } - - func updateUIViewController(_ uiViewController: HomeViewController, context: Context) { - } -} - final class HomeViewController: UIViewController { // MARK: UIConstants enum UIConstants { - static let interSectionSpacing: CGFloat = 20 + static let spacing: CGFloat = 20 static let layoutWidth: CGFloat = 1 static let layoutGroupHeight: CGFloat = 250 static let layoutHeaderHeight: CGFloat = 20 } - let logger = Logger() - - lazy var categoriesCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout()) - - // MARK: DataSources typealias DataSource = UICollectionViewDiffableDataSource typealias Snapshot = NSDiffableDataSourceSnapshot - lazy var dataSource = makeDataSource() - lazy var dataProvider = MockDataProvider() - lazy var cancellables = Set() + + // MARK: Private properties + private lazy var dataSource = makeDataSource() + private var dataProvider = DataProvider() + private lazy var cancellables = Set() + private let jokeService = JokeService(apiManager: APIManager()) + private lazy var categoriesCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout()) + private let logger = Logger() + private let storage = StorageManager() + private let eventSubject = PassthroughSubject() override func viewDidLoad() { super.viewDidLoad() - setup() title = "Categories" + setup() + } +} + +// MARK: - EventEmitting +extension HomeViewController: EventEmitting { + var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } +} + +// MARK: - UI SETUP +private extension HomeViewController { + func setup() { + setupCollectionView() + readData() + loadData() + } + func setupCollectionView() { + categoriesCollectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + categoriesCollectionView.backgroundColor = .bg + categoriesCollectionView.delegate = self + + view.addSubview(categoriesCollectionView) + + categoriesCollectionView.register( + UICollectionViewCell.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader + ) + } +} + +// MARK: - UICollectionViewDelegate +extension HomeViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + } + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + } +} + +// MARK: - Loading data +extension HomeViewController { + func loadData() { + Task { + let categories = try await jokeService.loadCategories() + + try await withThrowingTaskGroup(of: JokeResponse.self) { [weak self] group in + guard let self else { + return + } + + for category in categories { + // swiftlint:disable:next no_magic_numbers + for _ in 1...5 { + group.addTask { + try await self.jokeService.loadJokeForCategory(category) + } + } + } + + var jokeResponses: [JokeResponse] = [] + for try await jokeResponse in group { + jokeResponses.append(jokeResponse) + } + + let dataDictionary = Dictionary(grouping: jokeResponses, by: { $0.categories.first ?? "" }) + for key in dataDictionary.keys { + dataProvider.data.append(SectionData(title: key, jokes: dataDictionary[key] ?? [])) + } + + for sectionIndex in dataProvider.data.indices { + for jokeIndex in dataProvider.data[sectionIndex].jokes.indices { + let isLiked = try await storage.liked(jokeId: dataProvider.data[sectionIndex].jokes[jokeIndex].jokeID) + dataProvider.data[sectionIndex].jokes[jokeIndex].isLiked = isLiked + } + } + } + } } } // MARK: - UICollectionViewDataSource private extension HomeViewController { func readData() { + categoriesCollectionView.reloadData() dataProvider.$data.sink { [weak self] data in self?.applySnapshot(data: data, animatingDifferences: true) } @@ -58,44 +129,52 @@ private extension HomeViewController { } func applySnapshot(data: [SectionData], animatingDifferences: Bool = true) { - guard dataSource.snapshot().numberOfSections == 0 else { - var snapshot = dataSource.snapshot() - - // swiftlint:disable force_unwrapping - snapshot.moveItem((data.first?.jokes.first)!, afterItem: (data.first?.jokes.last)!) - // swiftlint:enable force_unwrapping - - dataSource.apply(snapshot, animatingDifferences: animatingDifferences) - return - } - var snapshot = Snapshot() snapshot.appendSections(data) - data.forEach { section in snapshot.appendItems(section.jokes, toSection: section) } - dataSource.apply(snapshot, animatingDifferences: animatingDifferences) } func makeDataSource() -> DataSource { let cellRegistration = UICollectionView.CellRegistration { cell, _, joke in cell.contentConfiguration = UIHostingConfiguration { - Image(uiImage: joke.image ?? UIImage()) - .resizableBordered(cornerRadius: CornerRadiusSize.default.rawValue) + if let url = try? ImagesRouter.size300x200.asURLRequest().url { + ZStack(alignment: .topTrailing) { + AsyncImage(url: url) { image in + image + .resizableBordered(cornerRadius: CornerRadiusSize.default.rawValue) + .scaledToFit() + } placeholder: { + Color.gray + } + if joke.isLiked ?? false { + Image(systemName: "heart.circle.fill") + .font(.largeTitle) + .symbolRenderingMode(.multicolor) + .padding(PaddingSize.default.rawValue) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } + .onTapGesture { [weak self] in + self?.eventSubject.send(.itemTapped(joke)) + } + } else { + Text("ERROR") + } } } - + let dataSource = DataSource(collectionView: categoriesCollectionView) { collectionView, indexPath, item in collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) } - + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in guard kind == UICollectionView.elementKindSectionHeader else { return nil } - + let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] let labelCell: UICollectionViewCell = collectionView.dequeueSupplementaryView( ofKind: UICollectionView.elementKindSectionHeader, @@ -107,65 +186,23 @@ private extension HomeViewController { } return labelCell } - + return dataSource } } -// MARK: - UICollectionViewDelegate -extension HomeViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - logger.info("I have tapped \(indexPath)") - } - - func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - logger.info("will display \(indexPath)") - } -} - -// MARK: - UI SETUP -private extension HomeViewController { - func setup() { - setupCollectionView() - readData() - } - func setupCollectionView() { - categoriesCollectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - categoriesCollectionView.backgroundColor = .bg - categoriesCollectionView.delegate = self - categoriesCollectionView.register( - UICollectionViewCell.self, - forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader - ) - view.addSubview(categoriesCollectionView) - } -} - // MARK: - Layout private extension HomeViewController { - func createCompositionalLayout() -> UICollectionViewLayout { - let layout = UICollectionViewCompositionalLayout { sectionIndex, _ in - let section = self.dataProvider.data[sectionIndex] - - switch section.title { - default: - return self.createSection(with: section) - } - } - - let config = UICollectionViewCompositionalLayoutConfiguration() - config.interSectionSpacing = UIConstants.interSectionSpacing - layout.configuration = config - return layout - } - - func createSection(with section: SectionData) -> NSCollectionLayoutSection { + func createCompositionalLayout() -> UICollectionViewCompositionalLayout { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize) + layoutItem.contentInsets.trailing = UIConstants.spacing + layoutItem.contentInsets.leading = UIConstants.spacing let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(UIConstants.layoutWidth), heightDimension: .estimated(UIConstants.layoutGroupHeight)) let layoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: layoutGroupSize, subitems: [layoutItem]) + layoutGroup.interItemSpacing = NSCollectionLayoutSpacing.fixed(UIConstants.spacing) let layoutSection = NSCollectionLayoutSection(group: layoutGroup) layoutSection.orthogonalScrollingBehavior = .groupPagingCentered @@ -174,6 +211,12 @@ private extension HomeViewController { let layoutSectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: layoutSectionHeaderSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) layoutSection.boundarySupplementaryItems = [layoutSectionHeader] - return layoutSection + let config = UICollectionViewCompositionalLayoutConfiguration() + config.interSectionSpacing = UIConstants.spacing + + let layout = UICollectionViewCompositionalLayout(section: layoutSection) + layout.configuration = config + + return layout } } diff --git a/App/Course App/Scenes/Home/HomeViewEvent.swift b/App/Course App/Scenes/Home/HomeViewEvent.swift new file mode 100644 index 0000000..0820a98 --- /dev/null +++ b/App/Course App/Scenes/Home/HomeViewEvent.swift @@ -0,0 +1,12 @@ +// +// HomeViewEvent.swift +// Course App +// +// Created by Tomáš Duchoslav on 12.06.2024. +// + +import Foundation + +enum HomeViewEvent { + case itemTapped(Joke) +} diff --git a/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinator.swift b/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinator.swift index 91c58cf..86eca4c 100644 --- a/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinator.swift +++ b/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinator.swift @@ -5,14 +5,30 @@ // Created by Tomáš Duchoslav on 30.05.2024. // +import Combine +import os import UIKit -final class HomeNavigationCoordinator: NavigationControllerCoordinator { +final class HomeNavigationCoordinator: NavigationControllerCoordinator, CancellablesContaining, SwipingViewFactory { // MARK: Private properties private(set) lazy var navigationController: UINavigationController = CustomNavigationController() + private let logger = Logger() + private let eventSubject = PassthroughSubject() // MARK: Public properties var childCoordinators = [Coordinator]() + var cancellables = Set() + + deinit { + logger.info("Deinit HomeNavigationCoordinator") + } +} + +// MARK: - EventEmitting +extension HomeNavigationCoordinator: EventEmitting { + var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } } // MARK: - Start coordinator @@ -25,6 +41,22 @@ extension HomeNavigationCoordinator { // MARK: - Factory methods private extension HomeNavigationCoordinator { func makeHomeView() -> UIViewController { - HomeViewController() + let homeView = HomeViewController() + homeView.eventPublisher.sink { [weak self] event in + self?.handle(event) + } + .store(in: &cancellables) + return homeView + } +} + +// MARK: - Handling events +private extension HomeNavigationCoordinator { + func handle(_ event: HomeViewEvent) { + switch event { + case let .itemTapped(joke): + logger.info("Joke on home screen was tapped \(joke.text)") + navigationController.pushViewController(makeSwipingView(with: joke), animated: true) + } } } diff --git a/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinatorEvent.swift b/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinatorEvent.swift new file mode 100644 index 0000000..177ad49 --- /dev/null +++ b/App/Course App/Scenes/Home/Navigation/HomeNavigationCoordinatorEvent.swift @@ -0,0 +1,11 @@ +// +// HomeNavigationCoordinatorEvent.swift +// Course App +// +// Created by Tomáš Duchoslav on 12.06.2024. +// + +import Foundation + +enum HomeNavigationCoordinatorEvent { +} diff --git a/App/Course App/Scenes/Navigation/MainTabBarCoordinator.swift b/App/Course App/Scenes/Navigation/MainTabBarCoordinator.swift index 264c91c..d88a8e6 100644 --- a/App/Course App/Scenes/Navigation/MainTabBarCoordinator.swift +++ b/App/Course App/Scenes/Navigation/MainTabBarCoordinator.swift @@ -10,15 +10,20 @@ import os import SwiftUI import UIKit -class MainTabBarCoordinator: NSObject, TabBarControllerCoordinator { +final class MainTabBarCoordinator: NSObject, TabBarControllerCoordinator, CancellablesContaining, OnboardingCoordinatorPresenting { // MARK: Private properties private(set) lazy var tabBarController = makeTabBarController() private var logger = Logger() - private lazy var anyCancellables = Set() private let eventSubject = PassthroughSubject() // MARK: Public Properties + var cancellables = Set() var childCoordinators = [Coordinator]() + + // MARK: Lifecycle + deinit { + logger.info("Deinit MainTabBarCoordinator") + } } // MARK: - Start the coordinator @@ -45,7 +50,6 @@ extension MainTabBarCoordinator { switch deeplink { case let .onboarding(page): let coordinator = makeOnboardingFlow() - startChildCoordinator(coordinator) tabBarController.present(coordinator.rootViewController, animated: true) default: break @@ -63,15 +67,6 @@ private extension MainTabBarCoordinator { return tabBarController } - func makeOnboardingFlow() -> ViewControllerCoordinator { - let coordinator = OnboardingNavigationCoordinator() - coordinator.eventPublisher.sink { [weak self] event in - self?.handle(event: event) - } - .store(in: &anyCancellables) - return coordinator - } - func makeHomeFlow() -> ViewControllerCoordinator { let homeViewCoordinator = HomeNavigationCoordinator() startChildCoordinator(homeViewCoordinator) @@ -96,7 +91,7 @@ private extension MainTabBarCoordinator { profileNavigationCoordinator.eventPublisher.sink { [weak self] event in self?.handle(event) } - .store(in: &anyCancellables) + .store(in: &cancellables) // swiftlint:disable:next no_magic_numbers profileNavigationCoordinator.rootViewController.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2) return profileNavigationCoordinator @@ -126,3 +121,6 @@ extension MainTabBarCoordinator: UITabBarControllerDelegate { logger.info("MainTabBarDelegate on coordinator and didSelect tab with controller \(viewController) ") } } + +// MARK: - UINavigationControllerDelegate +extension MainTabBarCoordinator: UINavigationControllerDelegate {} diff --git a/App/Course App/Scenes/Onboarding/Navigation/OnboardingCoordinatorPresenting.swift b/App/Course App/Scenes/Onboarding/Navigation/OnboardingCoordinatorPresenting.swift new file mode 100644 index 0000000..26ceaad --- /dev/null +++ b/App/Course App/Scenes/Onboarding/Navigation/OnboardingCoordinatorPresenting.swift @@ -0,0 +1,37 @@ +// +// OnboardingCoordinatorPresenting.swift +// Course App +// +// Created by Tomáš Duchoslav on 05.06.2024. +// + +import Combine +import UIKit + +protocol OnboardingCoordinatorPresenting { + func handle(_ event: OnboardingNavigationCoordinatorEvent) +} + +extension OnboardingCoordinatorPresenting where Self: Coordinator, Self: CancellablesContaining, Self: UINavigationControllerDelegate { + func makeOnboardingFlow(navigationController: UINavigationController? = nil) -> ViewControllerCoordinator { + let coordinator = OnboardingNavigationCoordinator(navigationController: navigationController) + startChildCoordinator(coordinator) + + if navigationController != nil { + coordinator.navigationController.delegate = self + } + + coordinator.eventPublisher.sink { [weak self] event in + self?.handle(event) + } + .store(in: &cancellables) + return coordinator + } + + func handle(_ event: OnboardingNavigationCoordinatorEvent) { + switch event { + case .dismiss(let coordinator): + release(coordinator) + } + } +} diff --git a/App/Course App/Scenes/Onboarding/Navigation/OnboardingNavigationCoordinator.swift b/App/Course App/Scenes/Onboarding/Navigation/OnboardingNavigationCoordinator.swift index 467173b..6b1f23d 100644 --- a/App/Course App/Scenes/Onboarding/Navigation/OnboardingNavigationCoordinator.swift +++ b/App/Course App/Scenes/Onboarding/Navigation/OnboardingNavigationCoordinator.swift @@ -10,19 +10,27 @@ import os import SwiftUI import UIKit -class OnboardingNavigationCoordinator: NavigationControllerCoordinator { +final class OnboardingNavigationCoordinator: NavigationControllerCoordinator, CancellablesContaining { // MARK: Private properties private(set) lazy var navigationController: UINavigationController = makeNavigationController() private let logger = Logger() - private var anyCancellables = Set() private let eventSubject = PassthroughSubject() + private var isPushNavigation = false // MARK: Public properties + var cancellables = Set() var childCoordinators = [Coordinator]() deinit { logger.info("Deinit OnboardingNavigationCoordinator") } + + init(navigationController: UINavigationController? = nil) { + if let navigationController { + isPushNavigation = true + self.navigationController = navigationController + } + } } // MARK: - Event emitter @@ -35,7 +43,14 @@ extension OnboardingNavigationCoordinator: EventEmitting { // MARK: - Start coordinator extension OnboardingNavigationCoordinator { func start() { - navigationController.setViewControllers([makeOnboardingView(page: OnboardingPage.welcome)], animated: false) + if isPushNavigation { + navigationController.pushViewController(makeOnboardingView(page: OnboardingPage.welcome), animated: true) + } else { + navigationController.setViewControllers( + [makeOnboardingView(page: OnboardingPage.welcome)], + animated: false + ) + } } } @@ -49,8 +64,7 @@ private extension OnboardingNavigationCoordinator { } self.eventSubject.send(.dismiss(self)) } - .store(in: &anyCancellables) - controller.modalPresentationStyle = .fullScreen + .store(in: &cancellables) controller.modalTransitionStyle = .crossDissolve return controller } @@ -74,10 +88,14 @@ private extension OnboardingNavigationCoordinator { let viewController = self.makeOnboardingView(page: newPage) self.navigationController.pushViewController(viewController, animated: true) case .close: - self.navigationController.dismiss(animated: true) + if navigationController.presentingViewController != nil { + self.navigationController.dismiss(animated: true) + } else { + navigationController.popToRootViewController(animated: true) + } } } - .store(in: &anyCancellables) + .store(in: &cancellables) return UIHostingController(rootView: onboardingView) } diff --git a/App/Course App/Scenes/Profile/Navigation/ProfileNavigationCoordinator.swift b/App/Course App/Scenes/Profile/Navigation/ProfileNavigationCoordinator.swift index f04f6fe..ab272b6 100644 --- a/App/Course App/Scenes/Profile/Navigation/ProfileNavigationCoordinator.swift +++ b/App/Course App/Scenes/Profile/Navigation/ProfileNavigationCoordinator.swift @@ -6,15 +6,24 @@ // import Combine +import os import SwiftUI import UIKit -final class ProfileNavigationCoordinator: NavigationControllerCoordinator { +final class ProfileNavigationCoordinator: NSObject, NavigationControllerCoordinator, CancellablesContaining, OnboardingCoordinatorPresenting { + // MARK: Private properties private(set) lazy var navigationController = makeNavigationController() private let eventSubject = PassthroughSubject() - private var anyCancellables = Set() + private let logger = Logger() + // MARK: Public properties + var cancellables = Set() var childCoordinators = [Coordinator]() + + // MARK: Lifecycle + deinit { + logger.info("Deinit ProfileNavigationCoordinator") + } } // MARK: - Start coordinator @@ -47,12 +56,25 @@ private extension ProfileNavigationCoordinator { switch event { case .logout: eventSubject.send(.logout) - default: - break + case .onboardingModal: + navigationController.present(makeOnboardingFlow().rootViewController, animated: true) + case .onboarding: + _ = makeOnboardingFlow(navigationController: self.navigationController) } } - .store(in: &anyCancellables) + .store(in: &cancellables) return UIHostingController(rootView: profileView) } } + +// MARK: - UINavigationControllerDelegate +extension ProfileNavigationCoordinator: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + if navigationController.viewControllers.count == 1 { + if let onboardingCoordinator = childCoordinators.first(where: { $0 is OnboardingNavigationCoordinator }) { + release(onboardingCoordinator) + } + } + } +} diff --git a/App/Course App/Scenes/Profile/ProfileView.swift b/App/Course App/Scenes/Profile/ProfileView.swift index 4f3e3c4..e6cf215 100644 --- a/App/Course App/Scenes/Profile/ProfileView.swift +++ b/App/Course App/Scenes/Profile/ProfileView.swift @@ -6,10 +6,14 @@ // import Combine +import os import SwiftUI struct ProfileView: View { + // MARK: Private properties private let eventSubject = PassthroughSubject() + private let authManager = FirebaseAuthManager() + private let logger = Logger() var body: some View { VStack { @@ -29,7 +33,7 @@ struct ProfileView: View { .buttonStyle(.navigationButtonStyle) Button("Logout") { - eventSubject.send(.logout) + signOut() } .buttonStyle(.navigationButtonStyle) } @@ -43,6 +47,21 @@ extension ProfileView: EventEmitting { } } +// MARK: - Functions +private extension ProfileView { + @MainActor + func signOut() { + Task { + do { + try await authManager.signOut() + eventSubject.send(.logout) + } catch { + logger.info("\(error)") + } + } + } +} + #Preview { ProfileView() } diff --git a/App/Course App/Scenes/SignIn/Navigation/SignInNavigationCoordinator.swift b/App/Course App/Scenes/SignIn/Navigation/SignInNavigationCoordinator.swift index 570cc3e..7a24549 100644 --- a/App/Course App/Scenes/SignIn/Navigation/SignInNavigationCoordinator.swift +++ b/App/Course App/Scenes/SignIn/Navigation/SignInNavigationCoordinator.swift @@ -10,15 +10,15 @@ import os import SwiftUI import UIKit -final class SignInNavigationCoordinator: NavigationControllerCoordinator { +final class SignInNavigationCoordinator: NavigationControllerCoordinator, CancellablesContaining { // MARK: Private properties private(set) lazy var navigationController = makeNavigationController() private let eventSubject = PassthroughSubject() - private var anyCancellables = Set() private let logger = Logger() // MARK: Public properties var childCoordinators = [Coordinator]() + var cancellables = Set() deinit { logger.info("Deinit SignInVIew") @@ -50,7 +50,7 @@ private extension SignInNavigationCoordinator { signInView.eventPublisher.sink { [weak self] event in self?.handle(event) } - .store(in: &anyCancellables) + .store(in: &cancellables) return UIHostingController(rootView: signInView) } } diff --git a/App/Course App/Scenes/SignIn/SignInView.swift b/App/Course App/Scenes/SignIn/SignInView.swift index 798bb64..fe4ff1d 100644 --- a/App/Course App/Scenes/SignIn/SignInView.swift +++ b/App/Course App/Scenes/SignIn/SignInView.swift @@ -6,20 +6,29 @@ // import Combine +import os import SwiftUI struct SignInView: View { // MARK: Private properties - @State private var emailField = "" - @State private var passwordField = "" + @State private var emailField: String + @State private var passwordField: String private let eventSubject = PassthroughSubject() + private let authManager = FirebaseAuthManager() + private let logger = Logger() + + // MARK: Lifecycle + init(emailField: String = "Test@test.test", passwordField: String = "test123") { + self.emailField = emailField + self.passwordField = passwordField + } var body: some View { Form { TextField("Email", text: $emailField) SecureField("password", text: $passwordField) Button("SignIn") { - eventSubject.send(.signedIn) + signIn() } } .navigationTitle("SignIn") @@ -33,6 +42,21 @@ extension SignInView: EventEmitting { } } +// MARK: - Functions +private extension SignInView { + @MainActor + func signIn() { + Task { + do { + try await authManager.signIn(Credentials(email: emailField, password: passwordField)) + eventSubject.send(.signedIn) + } catch { + logger.info("ERROR: \(error)") + } + } + } +} + #Preview { SignInView() } diff --git a/App/Course App/Scenes/SwipingView/Navigation/SwipingViewFactory.swift b/App/Course App/Scenes/SwipingView/Navigation/SwipingViewFactory.swift new file mode 100644 index 0000000..c8395c9 --- /dev/null +++ b/App/Course App/Scenes/SwipingView/Navigation/SwipingViewFactory.swift @@ -0,0 +1,19 @@ +// +// SwipingViewFactory.swift +// Course App +// +// Created by Tomáš Duchoslav on 12.06.2024. +// + +import SwiftUI +import UIKit + +protocol SwipingViewFactory { + func makeSwipingView(with joke: Joke?) -> UIViewController +} + +extension SwipingViewFactory { + func makeSwipingView(with joke: Joke? = nil) -> UIViewController { + UIHostingController(rootView: SwipingView(joke: joke)) + } +} diff --git a/App/Course App/Scenes/SwipingView/SwipingView.swift b/App/Course App/Scenes/SwipingView/SwipingView.swift index 105292f..1b07258 100644 --- a/App/Course App/Scenes/SwipingView/SwipingView.swift +++ b/App/Course App/Scenes/SwipingView/SwipingView.swift @@ -9,50 +9,126 @@ import os import SwiftUI struct SwipingView: View { - private let logger = Logger() - private let dataProvider = MockDataProvider() - + // MARK: Constants enum Constants { static let paddingDivider: CGFloat = 20 static let sizeDivider = 1.2 static let sizeWidthMultiplicator = 1.5 + static let fiveCard = 5 } - + + // MARK: Private properties + private let logger = Logger() + private let jokeService = JokeService(apiManager: APIManager()) + private let category: String? + @State private var jokes: [Joke] = [] + private let storage = StorageManager() + + init(joke: Joke? = nil) { + self.category = joke?.categories.first + if let joke { + self.jokes.append(joke) + } + } + + // MARK: View var body: some View { GeometryReader { geometry in HStack { Spacer() - + VStack { - if let jokes = dataProvider.data.first?.jokes { - ZStack { - ForEach(jokes, id: \.self) { joke in - SwipingCard( - configuration: SwipingCard.Configuration( - image: Image(uiImage: joke.image ?? UIImage()), - title: "Category", - description: joke.text - ), - swipeStateAction: { _ in } - ) - } + ZStack { + ForEach(jokes, id: \.self) { joke in + SwipingCard( + configuration: SwipingCard.Configuration( + title: joke.categories.first ?? "", + description: joke.text, + isLiked: joke.isLiked ?? false + ), + swipeStateAction: { action in + switch action { + case let .finished(direction): + storeJokeLike(jokeId: joke.jokeID, liked: direction == .left) + removeCard(of: joke) + checkCardStack() + default: + break + } + } + ) } - .padding(.top, geometry.size.height / Constants.paddingDivider) - .frame(width: geometry.size.width / Constants.sizeDivider, height: (geometry.size.width / Constants.sizeDivider) * Constants.sizeWidthMultiplicator) - } else { - Text("Empty data!") } - + .padding(.top, geometry.size.height / Constants.paddingDivider) + .frame(width: geometry.size.width / Constants.sizeDivider, height: (geometry.size.width / Constants.sizeDivider) * Constants.sizeWidthMultiplicator) + Spacer() } - Spacer() } } .navigationTitle("Random") + .onFirstAppear { + loadRandomJokes() + } } } #Preview { SwipingView() } + +// MARK: - Functions +extension SwipingView { + // MARK: Loading jokes + func loadRandomJokes() { + Task { + try await withThrowingTaskGroup(of: JokeResponse.self) { group in + // swiftlint:disable:next no_magic_numbers + for _ in 1...10 { + group.addTask { + if let category { + try await jokeService.loadJokeForCategory(category) + } else { + try await jokeService.loadRandomJoke() + } + } + } + + for try await jokeResponse in group { + let isLiked = try await storage.liked(jokeId: jokeResponse.id) + jokes.append(Joke(jokeResponse: jokeResponse, isLiked: isLiked)) + } + logger.info("INFO: Count cards is \(jokes.count).") + } + } + } + + // MARK: Storing joke like + func storeJokeLike(jokeId: String, liked: Bool) { + Task { + try await storage.storeLike(jokeId: jokeId, liked: liked) + } + } + + // MARK: Check for jokes count + func checkCardStack() { + if jokes.count <= Constants.fiveCard { + loadRandomJokes() + } + } + + // MARK: Remove joke + func removeCard(of joke: Joke) { + if let index = jokes.firstIndex(of: joke) { + loggerInfo(message: "INFO: Card number \(index) removed from Jokes array") + jokes.remove(at: index) + loggerInfo(message: "INFO: Cards count is \(jokes.count)") + } + } + + // MARK: Logger + func loggerInfo(message: String) { + logger.info("\(message)") + } +} diff --git a/App/Course App/SharedUI/ViewModifiers/OnFirstAppear.swift b/App/Course App/SharedUI/ViewModifiers/OnFirstAppear.swift new file mode 100644 index 0000000..beef558 --- /dev/null +++ b/App/Course App/SharedUI/ViewModifiers/OnFirstAppear.swift @@ -0,0 +1,37 @@ +// +// OnFirstAppear.swift +// Course App +// +// Created by Tomáš Duchoslav on 10.06.2024. +// + +import SwiftUI + +private struct OnFirstAppear: ViewModifier { + // MARK: Private variables + @State private var viewDidAppear = false + private let action: VoidAction? + + // MARK: Lifecycle + init(perform action: VoidAction? = nil) { + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear { + if !viewDidAppear { + viewDidAppear = true + + action?() + } + } + } +} + +extension View { + func onFirstAppear(perform action: (() -> Void)? = nil) -> some View { + modifier( + OnFirstAppear(perform: action) + ) + } +} diff --git a/App/Course App/SharedUI/Views/SwiftUI/ScratchView.swift b/App/Course App/SharedUI/Views/SwiftUI/ScratchView.swift index 75d33a7..99af11b 100644 --- a/App/Course App/SharedUI/Views/SwiftUI/ScratchView.swift +++ b/App/Course App/SharedUI/Views/SwiftUI/ScratchView.swift @@ -14,7 +14,6 @@ struct Line { struct ScratchView: View { // MARK: Variables - let image: Image let text: String @State private var currentLine = Line() @@ -22,31 +21,39 @@ struct ScratchView: View { var body: some View { ZStack(alignment: .top) { - image - .resizable() - .bordered(cornerRadius: CornerRadiusSize.default.rawValue) - .scaledToFit() - .padding(1) - .overlay { - RoundedRectangle(cornerRadius: CornerRadiusSize.default.rawValue) - .fill(.bg) + if let url = try? ImagesRouter.size300x200.asURLRequest().url { + AsyncImage(url: url) { image in + image + .resizable() + .bordered(cornerRadius: CornerRadiusSize.default.rawValue) + .scaledToFit() + .padding(1) .overlay { - Text(text) - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .padding() - } - .mask( - Canvas { context, _ in - for line in lines { - var path = Path() - path.addLines(line.points) - context.stroke(path, with: .color(.white), style: StrokeStyle(lineWidth: line.lineWidth, lineCap: .round, lineJoin: .round)) + RoundedRectangle(cornerRadius: CornerRadiusSize.default.rawValue) + .fill(.bg) + .overlay { + Text(text) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding() } - } - ) - .gesture(dragGesture) + .mask( + Canvas { context, _ in + for line in lines { + var path = Path() + path.addLines(line.points) + context.stroke(path, with: .color(.white), style: StrokeStyle(lineWidth: line.lineWidth, lineCap: .round, lineJoin: .round)) + } + } + ) + .gesture(dragGesture) + } + } placeholder: { + Color.gray } + } else { + Text("ERROR") + } } } @@ -62,5 +69,5 @@ struct ScratchView: View { } #Preview { - ScratchView(image: Image("nature"), text: "Joke") + ScratchView(text: "Joke") } diff --git a/App/Course App/SharedUI/Views/SwiftUI/SwipingCard.swift b/App/Course App/SharedUI/Views/SwiftUI/SwipingCard.swift index 68c75d6..43fb9d6 100644 --- a/App/Course App/SharedUI/Views/SwiftUI/SwipingCard.swift +++ b/App/Course App/SharedUI/Views/SwiftUI/SwipingCard.swift @@ -7,8 +7,6 @@ import SwiftUI -typealias Action = (T) -> Void - struct SwipingCard: View { // MARK: - SwipeDirection enum SwipeDirection { @@ -25,9 +23,9 @@ struct SwipingCard: View { // MARK: - Configuration struct Configuration: Equatable { - let image: Image let title: String let description: String + let isLiked: Bool } // MARK: UI constant { @@ -58,7 +56,6 @@ struct SwipingCard: View { VStack { Spacer() ScratchView( - image: configuration.image, text: configuration.description ) Spacer() @@ -68,6 +65,15 @@ struct SwipingCard: View { } .background(color) .cornerRadius(CornerRadiusSize.default.rawValue) + .overlay { + if configuration.isLiked { + Image(systemName: "heart.circle.fill") + .font(.largeTitle) + .symbolRenderingMode(.multicolor) + .padding(PaddingSize.default.rawValue) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } .offset(x: offset.width, y: offset.height * UIConstants.offsetMultiplicator) .rotationEffect(.degrees(Double(offset.width / UIConstants.degreesDivider))) .gesture(dragGesture) @@ -143,9 +149,9 @@ struct Card_Previews: PreviewProvider { static var previews: some View { SwipingCard( configuration: SwipingCard.Configuration( - image: Image("nature"), title: "Card Title", - description: "This is a short description. This is a short description. This is a short description. This is a short description. This is a short description." + description: "This is a short description. This is a short description. This is a short description. This is a short description. This is a short description.", + isLiked: true ), swipeStateAction: { _ in } )