diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 09a560b25..0d73d3b49 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */; }; 08248D02BACA75CDC3B39A96 /* UserNotificationCenterSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69219A908D7C22E6EE6689AE /* UserNotificationCenterSpy.swift */; }; 095C0ACFC234E0550A6404C5 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */; }; + 095D3906CF2F940C2D2D17CC /* RoomFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */; }; 09713669577CDA8D012EE380 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6647C55D93508C7CE9D954A5 /* MatrixRustSDK */; }; 09AAF04B27732046C755D914 /* SoftLogoutViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */; }; 09C83DDDB07C28364F325209 /* MockRoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */; }; @@ -111,7 +112,6 @@ 2AA684867C20F62CF03E8698 /* MockUserIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13673F95EBA78D40C09CCE35 /* MockUserIndicatorController.swift */; }; 2ABF11717C64054CEF2819A3 /* RoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */; }; 2AD59AD5B09498EF8B3B04EC /* InvitesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */; }; - 2B9AEEC12B1BBE5BD61D0F5E /* UserSessionFlowCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3429142FE11930422E7CC1A0 /* UserSessionFlowCoordinatorStateMachine.swift */; }; 2BA59D0AEFB4B82A2EC2A326 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; }; 2BAA5B222856068158D0B3C6 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = B1E8B697DF78FE7F61FC6CA4 /* MatrixRustSDK */; }; 2C4C750D0039AFABDF24236C /* TemplateScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */; }; @@ -125,6 +125,7 @@ 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1FFC3336EB23374BBBFCC /* UIKitBackgroundTaskService.swift */; }; 30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; }; 30CC4F796B27BE8B1DFDBF5A /* NSEUserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAA2832D93EC7D2608703FB /* NSEUserSession.swift */; }; + 3113065AABBC14CEAE6843FA /* UserSessionFlowCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */; }; 33CAC1226DFB8B5D8447D286 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; 340D39DB87F3800D53A6A621 /* EmojiPickerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00245D40CD90FD71D6A05239 /* EmojiPickerScreen.swift */; }; 3471204F2CC05D4821C35F23 /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7A5D2323D7B6BF4913EB7EED /* landscape_test_image.jpg */; }; @@ -175,6 +176,7 @@ 492274DA6691EE985C2FCCAA /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; 496CC9D59ACFAB84FD9B3B5F /* AnalyticsPromptScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840E86A67DB2C92C09771EAD /* AnalyticsPromptScreenModels.swift */; }; 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; + 4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */; }; 4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052B2F924572AFD70B5F500E /* StartChatScreenViewModel.swift */; }; 4AAA8606FBA290E23D15422E /* AvatarHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC743C7A85E3171BCBF0A653 /* AvatarHeaderView.swift */; }; 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */; }; @@ -335,7 +337,6 @@ 8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; }; 8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; 8B7771E319436E542412A22C /* SlidingSyncListProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 074DA547928E85183066DB4A /* SlidingSyncListProxy.swift */; }; - 8B807DC963D1D4155A241BCC /* UserSessionFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9E67AAB66638C69626866C /* UserSessionFlowCoordinator.swift */; }; 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; 8CC12086CBF91A7E10CDC205 /* HomeScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */; }; @@ -539,6 +540,7 @@ D415764645491F10344FC6AC /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F18AECC9D38C2B6D85F99C /* Publisher.swift */; }; D4ACF3276F5D0DA28D4028C9 /* AnalyticsPromptScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8196D64EB9CF2AF1F43E4ED1 /* AnalyticsPromptScreenViewModelProtocol.swift */; }; D53B80EF02C1062E68659EDD /* ReportContentViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C19086DD16E9B38E25954 /* ReportContentViewModelTests.swift */; }; + D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */; }; D5C805F49B2C75DC3793E780 /* EmojiItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A243E04B58DC6E41FDCD82 /* EmojiItem.swift */; }; D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */; }; D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; @@ -615,6 +617,7 @@ F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */; }; F519DE17A3A0F760307B2E6D /* InviteUsersScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D155E09BF961BBA8F85263 /* InviteUsersScreenViewModel.swift */; }; F587A9AF25A262DE5A7B0369 /* ProgressTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F28551E81CE3700E5F1EC9B5 /* ProgressTracker.swift */; }; + F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; }; F656F92A63D3DC1978D79427 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; @@ -808,7 +811,6 @@ 33649299575BADC34924ABC6 /* InvitesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenCoordinator.swift; sourceTree = ""; }; 33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 340179A0FC1AD4AEDA7FC134 /* CreateRoomViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModelProtocol.swift; sourceTree = ""; }; - 3429142FE11930422E7CC1A0 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = ""; }; 342BEBC3C5FC3F9943C41C4C /* TemplateScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelProtocol.swift; sourceTree = ""; }; 351E89CE2ED9B73C5CC47955 /* TimelineReactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReactionsView.swift; sourceTree = ""; }; 3558A15CFB934F9229301527 /* RestorationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationToken.swift; sourceTree = ""; }; @@ -835,7 +837,6 @@ 3EF1AC723C2609C7705569CA /* MediaLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderTests.swift; sourceTree = ""; }; 3F40F48279322E504153AB0D /* MockClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockClientProxy.swift; sourceTree = ""; }; 3F684BDD23ECEADB3053BA5A /* DeveloperOptionsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenUITests.swift; sourceTree = ""; }; - 3F9E67AAB66638C69626866C /* UserSessionFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinator.swift; sourceTree = ""; }; 3FFDA99C98BE05F43A92343B /* test_pdf.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = test_pdf.pdf; sourceTree = ""; }; 40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 4132F882A984ED971338EE9D /* ReportContentScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenUITests.swift; sourceTree = ""; }; @@ -871,6 +872,7 @@ 4E47F18A9A077E351CEA10D4 /* TextBasedRoomTimelineViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineViewProtocol.swift; sourceTree = ""; }; 4F0CB536D1C3CC15AA740CC6 /* AuthenticationServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxyProtocol.swift; sourceTree = ""; }; 4F1DFE6E746539F33042D3A9 /* FormSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormSection.swift; sourceTree = ""; }; + 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = ""; }; 4FD6E621CC5E6D4830D96D2D /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = ""; }; 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItem.swift; sourceTree = ""; }; @@ -961,6 +963,7 @@ 7B04BD3874D736127A8156B8 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTaskRunner.swift; sourceTree = ""; }; 7B849D2FF2CC12BA411A1651 /* CreateRoomModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomModels.swift; sourceTree = ""; }; + 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCoordinatorProtocol.swift; sourceTree = ""; }; 7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactoryProtocol.swift; sourceTree = ""; }; 7D379E13DD9D987470A3C70C /* LoginServerInfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginServerInfoSection.swift; sourceTree = ""; }; @@ -1025,6 +1028,7 @@ 981663D961C94270FA035FD0 /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenUITests.swift; sourceTree = ""; }; 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = ""; }; + 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinator.swift; sourceTree = ""; }; 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModule.swift; sourceTree = ""; }; 9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = ""; }; 9B65A314DF40B6BBF775C2BC /* AnalyticsPromptScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenCoordinator.swift; sourceTree = ""; }; @@ -1138,6 +1142,7 @@ C830A64609CBD152F06E0457 /* NotificationConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationConstants.swift; sourceTree = ""; }; C843CF833BF6485B64AC87E1 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = ""; }; C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = ""; }; + C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinator.swift; sourceTree = ""; }; CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = ""; }; CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenCell.swift; sourceTree = ""; }; CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; @@ -1208,6 +1213,7 @@ E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerFactory.swift; sourceTree = ""; }; E80F9E9B93B6ECE9A937B1C6 /* FormRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRow.swift; sourceTree = ""; }; + E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorStateMachine.swift; sourceTree = ""; }; E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = ""; }; E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFixtures.swift; sourceTree = ""; }; @@ -1960,6 +1966,16 @@ path = Other; sourceTree = ""; }; + 593C7129C5927E25AD8B688F /* FlowCoordinators */ = { + isa = PBXGroup; + children = ( + 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, + C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */, + E8774CF614849664B5B3C2A1 /* UserSessionFlowCoordinatorStateMachine.swift */, + ); + path = FlowCoordinators; + sourceTree = ""; + }; 595B8797ED6A7489ABDCE384 /* ErrorHandling */ = { isa = PBXGroup; children = ( @@ -2203,6 +2219,7 @@ 086C19086DD16E9B38E25954 /* ReportContentViewModelTests.swift */, 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */, 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */, + 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */, EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */, 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */, 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */, @@ -2626,6 +2643,7 @@ CA89A2DD51B6BBE1DA55E263 /* Application.swift */, AC3F82523D6F48B926D6AF68 /* AppSettings.swift */, B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */, + 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */, 57F95CADD0A5DBD76B990FCB /* ServiceLocator.swift */, 780F74C73E826685A9DB289B /* Navigation */, ); @@ -2848,8 +2866,6 @@ isa = PBXGroup; children = ( 3558A15CFB934F9229301527 /* RestorationToken.swift */, - 3F9E67AAB66638C69626866C /* UserSessionFlowCoordinator.swift */, - 3429142FE11930422E7CC1A0 /* UserSessionFlowCoordinatorStateMachine.swift */, 0E8BDC092D817B68CD9040C5 /* UserSessionStore.swift */, BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */, ); @@ -2992,6 +3008,7 @@ children = ( A78C2592419CA4C76FBA8FD2 /* Application */, 0787F81684E503024BD0C051 /* Services */, + 593C7129C5927E25AD8B688F /* FlowCoordinators */, E59565F441830B19DBAE567C /* Screens */, C0937E3B06A8F0E2DB7C8241 /* Other */, 2ECFF6B05DAA37EB10DBF7E8 /* UITests */, @@ -3601,6 +3618,7 @@ D53B80EF02C1062E68659EDD /* ReportContentViewModelTests.swift in Sources */, 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */, EA974337FA7D040E7C74FE6E /* RoomDetailsViewModelTests.swift in Sources */, + 095D3906CF2F940C2D2D17CC /* RoomFlowCoordinatorTests.swift in Sources */, 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */, CAF8755E152204F55F8D6B5B /* RoomMembersListViewModelTests.swift in Sources */, 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */, @@ -3762,6 +3780,7 @@ D33AC79A50DFC26D2498DD28 /* FileRoomTimelineItem.swift in Sources */, 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */, 1F04C63D4FA95948E3F52147 /* FileRoomTimelineView.swift in Sources */, + F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */, B3EDDEC1839BB5A3747624BB /* FormButtonStyles.swift in Sources */, 5CE74302A0725F56F1E9D2A0 /* FormRow.swift in Sources */, 4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */, @@ -3894,6 +3913,7 @@ A5D551E5691749066E0E0C44 /* RoomDetailsScreenViewModel.swift in Sources */, E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */, 42F1C8731166633E35A6D7E6 /* RoomEventStringBuilder.swift in Sources */, + D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */, 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */, FA4296218444C48BC890F46B /* RoomMemberDetails.swift in Sources */, 19FE025AE9BA2959B6589B0D /* RoomMemberDetailsScreen.swift in Sources */, @@ -4047,8 +4067,8 @@ F94000E3D91B11C527DA8807 /* UserProfileCell.swift in Sources */, 9CCC77C31CB399661A034739 /* UserProperties+Element.swift in Sources */, 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */, - 8B807DC963D1D4155A241BCC /* UserSessionFlowCoordinator.swift in Sources */, - 2B9AEEC12B1BBE5BD61D0F5E /* UserSessionFlowCoordinatorStateMachine.swift in Sources */, + 4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */, + 3113065AABBC14CEAE6843FA /* UserSessionFlowCoordinatorStateMachine.swift in Sources */, 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */, 7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */, AC69B6DF15FC451AB2945036 /* UserSessionStoreProtocol.swift in Sources */, diff --git a/ElementX/Sources/Application/FlowCoordinatorProtocol.swift b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift new file mode 100644 index 000000000..8a5caaa4f --- /dev/null +++ b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@MainActor +protocol FlowCoordinatorProtocol { + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) +} diff --git a/ElementX/Sources/Application/Navigation/AppRouter.swift b/ElementX/Sources/Application/Navigation/AppRouter.swift index bdcf8cc82..ac24863d7 100644 --- a/ElementX/Sources/Application/Navigation/AppRouter.swift +++ b/ElementX/Sources/Application/Navigation/AppRouter.swift @@ -19,7 +19,9 @@ import Foundation import URLRouting enum AppRoute { + case roomList case room(roomID: String) + case roomDetails(roomID: String) } struct AppRouterManager { diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift new file mode 100644 index 000000000..90f21814c --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -0,0 +1,480 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import SwiftState + +enum RoomFlowCoordinatorAction: Equatable { + case presentedRoom(String) + case dismissedRoom +} + +class RoomFlowCoordinator: FlowCoordinatorProtocol { + private let userSession: UserSessionProtocol + private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol + private let navigationStackCoordinator: NavigationStackCoordinator + private let navigationSplitCoordinator: NavigationSplitCoordinator + private let emojiProvider: EmojiProviderProtocol + + private let stateMachine: StateMachine = .init(state: .initial) + + private var cancellables: Set = .init() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var roomProxy: RoomProxyProtocol? + private var timelineController: RoomTimelineControllerProtocol? + + init(userSession: UserSessionProtocol, + roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, + navigationStackCoordinator: NavigationStackCoordinator, + navigationSplitCoordinator: NavigationSplitCoordinator, + emojiProvider: EmojiProviderProtocol) { + self.userSession = userSession + self.roomTimelineControllerFactory = roomTimelineControllerFactory + self.navigationStackCoordinator = navigationStackCoordinator + self.navigationSplitCoordinator = navigationSplitCoordinator + self.emojiProvider = emojiProvider + + setupStateMachine() + } + + // MARK: - FlowCoordinatorProtocol + + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + switch appRoute { + case .room(let roomID): + if case .room(let identifier) = stateMachine.state, + roomID == identifier { + return + } + + stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated)) + case .roomDetails(let roomID): + stateMachine.tryEvent(.presentRoomDetails(roomID: roomID), userInfo: EventUserInfo(animated: animated)) + case .roomList: + stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated)) + } + } + + // MARK: - Private + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func setupStateMachine() { + stateMachine.addRouteMapping { event, fromState, _ in + switch (event, fromState) { + case (.presentRoom(let roomID), _): + return .room(roomID: roomID) + case (.dismissRoom, .room): + return .initial + + case (.presentRoomDetails(let roomID), .initial): + return .roomDetails(roomID: roomID) + case (.presentRoomDetails(let roomID), .room): + return .roomDetails(roomID: roomID) + case (.presentRoomDetails(let roomID), .roomDetails): + return .roomDetails(roomID: roomID) + case (.dismissRoomDetails, .roomDetails(let roomID)): + return .room(roomID: roomID) + case (.dismissRoom, .roomDetails): + return .initial + + case (.presentMediaViewer(let file, let title), .room(let roomID)): + return .mediaViewer(roomID: roomID, file: file, title: title) + case (.dismissMediaViewer, .mediaViewer(let roomID, _, _)): + return .room(roomID: roomID) + + case (.presentReportContent(let itemID, let senderID), .room(let roomID)): + return .reportContent(roomID: roomID, itemID: itemID, senderID: senderID) + case (.dismissReportContent, .reportContent(let roomID, _, _)): + return .room(roomID: roomID) + + case (.presentMediaUploadPicker(let source), .room(let roomID)): + return .mediaUploadPicker(roomID: roomID, source: source) + case (.dismissMediaUploadPicker, .mediaUploadPicker(let roomID, _)): + return .room(roomID: roomID) + + case (.presentMediaUploadPreview(let fileURL), .mediaUploadPicker(let roomID, _)): + return .mediaUploadPreview(roomID: roomID, fileURL: fileURL) + case (.presentMediaUploadPreview(let fileURL), .room(let roomID)): + return .mediaUploadPreview(roomID: roomID, fileURL: fileURL) + case (.dismissMediaUploadPreview, .mediaUploadPreview(let roomID, _)): + return .room(roomID: roomID) + + case (.presentEmojiPicker(let itemID), .room(let roomID)): + return .emojiPicker(roomID: roomID, itemID: itemID) + case (.dismissEmojiPicker, .emojiPicker(let roomID, _)): + return .room(roomID: roomID) + + default: + return nil + } + } + + stateMachine.addAnyHandler(.any => .any) { [weak self] context in + guard let self else { return } + + let animated = (context.userInfo as? EventUserInfo)?.animated ?? true + + switch (context.fromState, context.event, context.toState) { + case (_, .presentRoom(let roomID), .room): + presentRoom(roomID, animated: animated) + case (.room, .dismissRoom, .initial): + dismissRoom(animated: animated) + + case (.initial, .presentRoomDetails, .roomDetails(let roomID)), + (.room, .presentRoomDetails, .roomDetails(let roomID)), + (.roomDetails, .presentRoomDetails, .roomDetails(let roomID)): + Task { + await self.presentRoomDetails(roomID: roomID, animated: animated) + } + case (.roomDetails, .dismissRoomDetails, .room): + break + case (.roomDetails, .dismissRoom, .initial): + dismissRoom(animated: animated) + + case (.room, .presentMediaViewer, .mediaViewer(_, let file, let title)): + presentMediaViewer(file, title: title) + case (.mediaViewer, .dismissMediaViewer, .room): + break + + case (.room, .presentReportContent, .reportContent(_, let itemID, let senderID)): + presentReportContent(for: itemID, from: senderID) + case (.reportContent, .dismissReportContent, .room): + break + + case (.room, .presentMediaUploadPicker, .mediaUploadPicker(_, let source)): + presentMediaUploadPickerWithSource(source) + case (.mediaUploadPicker, .dismissMediaUploadPicker, .room): + break + + case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)): + presentMediaUploadPreviewScreen(for: fileURL) + case (.room, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)): + presentMediaUploadPreviewScreen(for: fileURL) + case (.mediaUploadPreview, .dismissMediaUploadPreview, .room): + break + + case (.room, .presentEmojiPicker, .emojiPicker(_, let itemID)): + presentEmojiPicker(for: itemID) + case (.emojiPicker, .dismissEmojiPicker, .room): + break + + default: + fatalError("Unknown transition: \(context)") + } + } + + stateMachine.addAnyHandler(.any => .any) { context in + if let event = context.event { + MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`") + } else { + MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`") + } + } + + stateMachine.addErrorHandler { context in + fatalError("Failed transition with context: \(context)") + } + } + + private func presentRoom(_ roomID: String, animated: Bool) { + Task { + await asyncPresentRoom(roomID, animated: animated) + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func asyncPresentRoom(_ roomID: String, animated: Bool) async { + if let roomProxy, roomProxy.id == roomID { + navigationStackCoordinator.popToRoot() + return + } + + guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else { + MXLog.error("Invalid room identifier: \(roomID)") + stateMachine.tryEvent(.dismissRoom) + return + } + + actionsSubject.send(.presentedRoom(roomID)) + + self.roomProxy = roomProxy + + let userId = userSession.clientProxy.userID + + let timelineItemFactory = RoomTimelineItemFactory(userID: userId, + mediaProvider: userSession.mediaProvider, + attributedStringBuilder: AttributedStringBuilder(), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: userId)) + + let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(userId: userId, + roomProxy: roomProxy, + timelineItemFactory: timelineItemFactory, + mediaProvider: userSession.mediaProvider) + self.timelineController = timelineController + + let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, + timelineController: timelineController, + mediaProvider: userSession.mediaProvider, + emojiProvider: emojiProvider) + let coordinator = RoomScreenCoordinator(parameters: parameters) + coordinator.actions + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .presentRoomDetails: + stateMachine.tryEvent(.presentRoomDetails(roomID: roomID)) + case .presentMediaViewer(let file, let title): + stateMachine.tryEvent(.presentMediaViewer(file: file, title: title)) + case .presentReportContent(let itemID, let senderID): + stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID)) + case .presentMediaUploadPicker(let source): + stateMachine.tryEvent(.presentMediaUploadPicker(source: source)) + case .presentMediaUploadPreviewScreen(let url): + stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url)) + case .presentEmojiPicker(let itemID): + stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID)) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in + // Move the state machine to no room selected if the room currently being dismissed + // is the same as the one selected in the state machine. + // This generally happens when popping the room screen while in a compact layout + switch self?.stateMachine.state { + case let .room(selectedRoomID) where selectedRoomID == roomID: + self?.stateMachine.tryEvent(.dismissRoom) + default: + break + } + } + + if navigationSplitCoordinator.detailCoordinator == nil { + navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated) + } + } + + private func dismissRoom(animated: Bool) { + navigationStackCoordinator.popToRoot(animated: true) + navigationSplitCoordinator.setDetailCoordinator(nil) + roomProxy = nil + + actionsSubject.send(.dismissedRoom) + } + + private func presentRoomDetails(roomID: String, animated: Bool) async { + if roomProxy?.id != roomID { + await asyncPresentRoom(roomID, animated: true) + } + + guard let roomProxy else { + fatalError() + } + + let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, + roomProxy: roomProxy, + mediaProvider: userSession.mediaProvider, + userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy)) + let coordinator = RoomDetailsScreenCoordinator(parameters: params) + coordinator.callback = { [weak self] action in + switch action { + case .cancel: + self?.navigationStackCoordinator.pop() + case .leftRoom: + self?.dismissRoom(animated: animated) + } + } + + navigationStackCoordinator.push(coordinator) { [weak self] in + guard let self else { return } + + if case .roomDetails = stateMachine.state { + stateMachine.tryEvent(.dismissRoomDetails) + } + } + } + + private func presentMediaViewer(_ file: MediaFileHandleProxy, title: String?) { + let params = FilePreviewScreenCoordinatorParameters(mediaFile: file, title: title) + let coordinator = FilePreviewScreenCoordinator(parameters: params) + coordinator.callback = { [weak self] action in + switch action { + case .cancel: + self?.navigationStackCoordinator.pop() + } + } + + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissMediaViewer) + } + } + + private func presentReportContent(for itemID: String, from senderID: String) { + guard let roomProxy else { + fatalError() + } + + let navigationCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: navigationCoordinator) + let parameters = ReportContentScreenCoordinatorParameters(itemID: itemID, + senderID: senderID, + roomProxy: roomProxy, + userIndicatorController: userIndicatorController) + let coordinator = ReportContentScreenCoordinator(parameters: parameters) + coordinator.callback = { [weak self] completion in + self?.navigationStackCoordinator.setSheetCoordinator(nil) + + switch completion { + case .cancel: + break + case .finish: + self?.showSuccess(label: L10n.commonReportSubmitted) + } + } + navigationCoordinator.setRootCoordinator(coordinator) + navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in + self?.stateMachine.tryEvent(.dismissReportContent) + } + } + + private func presentMediaUploadPickerWithSource(_ source: MediaPickerScreenSource) { + let stackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) + + let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in + switch action { + case .cancel: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + case .selectMediaAtURL(let url): + self?.stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url)) + } + } + + stackCoordinator.setRootCoordinator(mediaPickerCoordinator) + + navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in + if case .mediaUploadPicker = self?.stateMachine.state { + self?.stateMachine.tryEvent(.dismissMediaUploadPicker) + } + } + } + + private func presentMediaUploadPreviewScreen(for url: URL) { + guard let roomProxy else { + fatalError() + } + + let stackCoordinator = NavigationStackCoordinator() + let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) + + let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController, + roomProxy: roomProxy, + mediaUploadingPreprocessor: MediaUploadingPreprocessor(), + title: url.lastPathComponent, + url: url) + + let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in + switch action { + case .dismiss: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + + stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator) + + navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in + self?.stateMachine.tryEvent(.dismissMediaUploadPreview) + } + } + + private func presentEmojiPicker(for itemId: String) { + let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator() + + let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider, + itemId: itemId) + let coordinator = EmojiPickerScreenCoordinator(parameters: params) + coordinator.callback = { [weak self] action in + switch action { + case let .emojiSelected(emoji: emoji, itemId: itemId): + MXLog.debug("Selected \(emoji) for \(itemId)") + self?.navigationStackCoordinator.setSheetCoordinator(nil) + Task { + await self?.timelineController?.sendReaction(emoji, to: itemId) + } + case .dismiss: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + + emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator) + emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large] + + navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissEmojiPicker) + } + } + + private func showSuccess(label: String) { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark")) + } +} + +private extension RoomFlowCoordinator { + enum State: StateType { + case initial + case room(roomID: String) + case mediaViewer(roomID: String, file: MediaFileHandleProxy, title: String?) + case reportContent(roomID: String, itemID: String, senderID: String) + case roomDetails(roomID: String) + case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource) + case mediaUploadPreview(roomID: String, fileURL: URL) + case emojiPicker(roomID: String, itemID: String) + } + + struct EventUserInfo { + let animated: Bool + } + + enum Event: EventType { + case presentRoom(roomID: String) + case dismissRoom + + case presentMediaViewer(file: MediaFileHandleProxy, title: String?) + case dismissMediaViewer + + case presentReportContent(itemID: String, senderID: String) + case dismissReportContent + + case presentRoomDetails(roomID: String) + case dismissRoomDetails + + case presentMediaUploadPicker(source: MediaPickerScreenSource) + case dismissMediaUploadPicker + + case presentMediaUploadPreview(fileURL: URL) + case dismissMediaUploadPreview + + case presentEmojiPicker(itemID: String) + case dismissEmojiPicker + } +} diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift similarity index 62% rename from ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift rename to ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 2597d62fa..d74dd3727 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -22,15 +22,16 @@ enum UserSessionFlowCoordinatorAction { case clearCache } -class UserSessionFlowCoordinator: CoordinatorProtocol { - private let stateMachine: UserSessionFlowCoordinatorStateMachine - private var cancellables: Set = .init() - +class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let userSession: UserSessionProtocol private let navigationSplitCoordinator: NavigationSplitCoordinator private let bugReportService: BugReportServiceProtocol private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol - private let emojiProvider: EmojiProviderProtocol = EmojiProvider() + + private let stateMachine: UserSessionFlowCoordinatorStateMachine + private let roomFlowCoordinator: RoomFlowCoordinator + + private var cancellables: Set = .init() private let sidebarNavigationStackCoordinator: NavigationStackCoordinator private let detailNavigationStackCoordinator: NavigationStackCoordinator @@ -52,7 +53,23 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession, + roomTimelineControllerFactory: roomTimelineControllerFactory, + navigationStackCoordinator: detailNavigationStackCoordinator, + navigationSplitCoordinator: navigationSplitCoordinator, + emojiProvider: EmojiProvider()) + setupStateMachine() + + roomFlowCoordinator.actions.sink { action in + switch action { + case .presentedRoom(let roomID): + self.stateMachine.processEvent(.selectRoom(roomId: roomID)) + case .dismissedRoom: + self.stateMachine.processEvent(.deselectRoom) + } + } + .store(in: &cancellables) } func start() { @@ -64,6 +81,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool { stateMachine.isDisplayingRoomScreen(withRoomId: roomId) } + + // MARK: - FlowCoordinatorProtocol func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { switch stateMachine.state { @@ -72,9 +91,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { case .roomList, .initial: break } + switch appRoute { - case .room(let roomID): - stateMachine.processEvent(.selectRoom(roomId: roomID), userInfo: .init(animated: animated)) + case .room, .roomDetails, .roomList: + roomFlowCoordinator.handleAppRoute(appRoute, animated: true) } } @@ -84,20 +104,20 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { private func setupStateMachine() { stateMachine.addTransitionHandler { [weak self] context in guard let self else { return } - let animated = (context.userInfo as? EventUserInfo)?.animated ?? true + let animated = (context.userInfo as? UserSessionFlowCoordinatorStateMachine.EventUserInfo)?.animated ?? true switch (context.fromState, context.event, context.toState) { case (.initial, .start, .roomList): self.presentHomeScreen() - case(.roomList(let currentRoomId), .selectRoom, .roomList(let selectedRoomId)): - guard let selectedRoomId, - selectedRoomId != currentRoomId else { - return - } - - self.presentRoomWithIdentifier(selectedRoomId, animated: animated) + case(.roomList, .selectRoom, .roomList): + break case(.roomList, .deselectRoom, .roomList): break + + case (.invitesScreen, .selectRoom, .invitesScreen): + break + case (.invitesScreen, .deselectRoom, .invitesScreen): + break case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen): self.presentSessionVerification(animated: animated) @@ -123,16 +143,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.presentInvitesList(animated: animated) case (.invitesScreen, .closedInvitesScreen, .roomList): break - case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId: - self.presentRoomWithIdentifier(roomId, animated: animated) - case (.invitesScreen, .deselectRoom, .invitesScreen): - break - case (.roomList(let currentRoomId), .selectRoomDetails(let roomId), .roomList) where currentRoomId == roomId: - break - case (.roomList, .selectRoomDetails(let roomId), .roomList(let selectedRoomId)) where roomId == selectedRoomId: - self.presentRoomDetails(roomIdentifier: roomId, animated: animated) - default: fatalError("Unknown transition: \(context)") } @@ -143,6 +154,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { } } + // swiftlint:disable:next cyclomatic_complexity private func presentHomeScreen() { let parameters = HomeScreenCoordinatorParameters(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(), @@ -154,12 +166,15 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .presentRoom(let roomIdentifier): - self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier)) - case .presentRoomDetails(let roomIdentifier): - self.stateMachine.processEvent(.selectRoomDetails(roomId: roomIdentifier)) - case .roomLeft(let roomIdentifier): - self.deselectRoomIfNeeded(roomIdentifier: roomIdentifier) + case .presentRoom(let roomID): + self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) + case .presentRoomDetails(let roomID): + self.roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true) + case .roomLeft(let roomID): + if case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state, + selectedRoomId == roomID { + self.roomFlowCoordinator.handleAppRoute(.roomList, animated: true) + } case .presentSettingsScreen: self.stateMachine.processEvent(.showSettingsScreen) case .presentFeedbackScreen: @@ -178,114 +193,6 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { sidebarNavigationStackCoordinator.setRootCoordinator(coordinator) } - // MARK: Rooms - - private func presentRoomWithIdentifier(_ roomIdentifier: String, animated: Bool = true) { - Task { @MainActor in - guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else { - MXLog.error("Invalid room identifier: \(roomIdentifier)") - stateMachine.processEvent(.deselectRoom) - return - } - let userId = userSession.clientProxy.userID - - let timelineItemFactory = RoomTimelineItemFactory(userID: userId, - mediaProvider: userSession.mediaProvider, - attributedStringBuilder: AttributedStringBuilder(), - stateEventStringBuilder: RoomStateEventStringBuilder(userID: userId)) - - let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(userId: userId, - roomProxy: roomProxy, - timelineItemFactory: timelineItemFactory, - mediaProvider: userSession.mediaProvider) - - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator, - roomProxy: roomProxy, - timelineController: timelineController, - mediaProvider: userSession.mediaProvider, - emojiProvider: emojiProvider, - userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy)) - let coordinator = RoomScreenCoordinator(parameters: parameters) - coordinator.callback = { [weak self] action in - switch action { - case .leftRoom: - self?.dismissRoom() - } - } - - detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in - guard let self else { return } - - // Move the state machine to no room selected if the room currently being dismissed - // is the same as the one selected in the state machine. - // This generally happens when popping the room screen while in a compact layout - switch self.stateMachine.state { - case - let .roomList(selectedRoomId) where selectedRoomId == roomIdentifier, - let .invitesScreen(selectedRoomId) where selectedRoomId == roomIdentifier: - - self.stateMachine.processEvent(.deselectRoom) - self.detailNavigationStackCoordinator.setRootCoordinator(nil) - default: - break - } - } - - if navigationSplitCoordinator.detailCoordinator == nil { - navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated) - } - } - } - - private func deselectRoomIfNeeded(roomIdentifier: String) { - guard - case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state, - selectedRoomId == roomIdentifier - else { - return - } - - stateMachine.processEvent(.deselectRoom) - navigationSplitCoordinator.setDetailCoordinator(nil) - } - - private func dismissRoom() { - detailNavigationStackCoordinator.popToRoot(animated: true) - navigationSplitCoordinator.setDetailCoordinator(nil) - } - - private func presentRoomDetails(roomIdentifier: String, animated: Bool = true) { - Task { - guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else { - MXLog.error("Invalid room identifier: \(roomIdentifier)") - return - } - - let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator, - roomProxy: roomProxy, - mediaProvider: userSession.mediaProvider, - userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy)) - - let coordinator = RoomDetailsScreenCoordinator(parameters: params) - - coordinator.callback = { [weak self] action in - switch action { - case .cancel, .leftRoom: - self?.stateMachine.processEvent(.deselectRoom) - self?.detailNavigationStackCoordinator.setRootCoordinator(nil) - } - } - - detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in - self?.deselectRoomIfNeeded(roomIdentifier: roomIdentifier) - } - - if navigationSplitCoordinator.detailCoordinator == nil { - navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated) - } - } - } - // MARK: Settings private func presentSettingsScreen(animated: Bool) { @@ -352,9 +259,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { switch action { case .close: self.navigationSplitCoordinator.setSheetCoordinator(nil) - case .openRoom(let identifier): + case .openRoom(let roomID): self.navigationSplitCoordinator.setSheetCoordinator(nil) - self.stateMachine.processEvent(.selectRoom(roomId: identifier)) + self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) } } .store(in: &cancellables) @@ -400,8 +307,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { coordinator.actions .sink { [weak self] action in switch action { - case .openRoom(let roomId): - self?.stateMachine.processEvent(.selectRoom(roomId: roomId)) + case .openRoom(let roomID): + self?.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift similarity index 95% rename from ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift rename to ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift index ad502e803..35ef23c7f 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinatorStateMachine.swift @@ -41,6 +41,10 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing invites list screen case invitesScreen(selectedRoomId: String?) } + + struct EventUserInfo { + let animated: Bool + } /// Events that can be triggered on the AppCoordinator state machine enum Event: EventType { @@ -77,9 +81,6 @@ class UserSessionFlowCoordinatorStateMachine { case showInvitesScreen /// The invites screen has been dismissed case closedInvitesScreen - - /// Request presentation of the settings of a specific room - case selectRoomDetails(roomId: String) } private let stateMachine: StateMachine @@ -101,8 +102,12 @@ class UserSessionFlowCoordinatorStateMachine { switch (event, fromState) { case (.selectRoom(let roomId), .roomList): return .roomList(selectedRoomId: roomId) + case (.selectRoom(let roomId), .invitesScreen): + return .invitesScreen(selectedRoomId: roomId) case (.deselectRoom, .roomList): return .roomList(selectedRoomId: nil) + case (.deselectRoom, .invitesScreen): + return .invitesScreen(selectedRoomId: nil) case (.showSettingsScreen, .roomList(let selectedRoomId)): return .settingsScreen(selectedRoomId: selectedRoomId) @@ -128,14 +133,7 @@ class UserSessionFlowCoordinatorStateMachine { return .invitesScreen(selectedRoomId: selectedRoomId) case (.closedInvitesScreen, .invitesScreen(let selectedRoomId)): return .roomList(selectedRoomId: selectedRoomId) - case (.selectRoom(let roomId), .invitesScreen): - return .invitesScreen(selectedRoomId: roomId) - case (.deselectRoom, .invitesScreen): - return .invitesScreen(selectedRoomId: nil) - case (.selectRoomDetails(let roomId), .roomList): - return .roomList(selectedRoomId: roomId) - default: return nil } @@ -176,7 +174,3 @@ class UserSessionFlowCoordinatorStateMachine { } } } - -struct EventUserInfo { - let animated: Bool -} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 2194c801e..ad27cb713 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -14,30 +14,34 @@ // limitations under the License. // +import Combine import SwiftUI struct RoomScreenCoordinatorParameters { - let navigationStackCoordinator: NavigationStackCoordinator let roomProxy: RoomProxyProtocol let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let emojiProvider: EmojiProviderProtocol - let userDiscoveryService: UserDiscoveryServiceProtocol } enum RoomScreenCoordinatorAction { - case leftRoom + case presentMediaViewer(file: MediaFileHandleProxy, title: String?) + case presentReportContent(itemID: String, senderID: String) + case presentMediaUploadPicker(MediaPickerScreenSource) + case presentMediaUploadPreviewScreen(URL) + case presentRoomDetails + case presentEmojiPicker(itemID: String) } final class RoomScreenCoordinator: CoordinatorProtocol { private var parameters: RoomScreenCoordinatorParameters private var viewModel: RoomScreenViewModelProtocol - private var navigationStackCoordinator: NavigationStackCoordinator { - parameters.navigationStackCoordinator - } - var callback: ((RoomScreenCoordinatorAction) -> Void)? + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } init(parameters: RoomScreenCoordinatorParameters) { self.parameters = parameters @@ -55,21 +59,21 @@ final class RoomScreenCoordinator: CoordinatorProtocol { switch action { case .displayRoomDetails: - self.displayRoomDetails() - case .displayMediaFile(let file, let title): - self.displayFilePreview(for: file, with: title) + actionsSubject.send(.presentRoomDetails) + case .displayMediaViewer(let file, let title): + actionsSubject.send(.presentMediaViewer(file: file, title: title)) case .displayEmojiPicker(let itemID): - self.displayEmojiPickerScreen(for: itemID) + actionsSubject.send(.presentEmojiPicker(itemID: itemID)) case .displayReportContent(let itemID, let senderID): - self.displayReportContent(for: itemID, from: senderID) + actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) case .displayCameraPicker: - self.displayMediaPickerWithSource(.camera) + actionsSubject.send(.presentMediaUploadPicker(.camera)) case .displayMediaPicker: - self.displayMediaPickerWithSource(.photoLibrary) + actionsSubject.send(.presentMediaUploadPicker(.photoLibrary)) case .displayDocumentPicker: - self.displayMediaPickerWithSource(.documents) + actionsSubject.send(.presentMediaUploadPicker(.documents)) case .displayMediaUploadPreviewScreen(let url): - self.displayMediaUploadPreviewScreenForFile(at: url) + actionsSubject.send(.presentMediaUploadPreviewScreen(url)) } } } @@ -81,123 +85,4 @@ final class RoomScreenCoordinator: CoordinatorProtocol { func toPresentable() -> AnyView { AnyView(RoomScreen(context: viewModel.context)) } - - // MARK: - Private - - private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) { - let stackCoordinator = NavigationStackCoordinator() - let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) - - let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in - switch action { - case .cancel: - self?.navigationStackCoordinator.setSheetCoordinator(nil) - case .selectMediaAtURL(let url): - self?.displayMediaUploadPreviewScreenForFile(at: url) - } - } - - stackCoordinator.setRootCoordinator(mediaPickerCoordinator) - - navigationStackCoordinator.setSheetCoordinator(userIndicatorController) - } - - private func displayMediaUploadPreviewScreenForFile(at url: URL) { - let stackCoordinator = NavigationStackCoordinator() - let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator) - - let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController, - roomProxy: parameters.roomProxy, - mediaUploadingPreprocessor: MediaUploadingPreprocessor(), - title: url.lastPathComponent, - url: url) - - let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in - switch action { - case .dismiss: - self?.navigationStackCoordinator.setSheetCoordinator(nil) - } - } - - stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator) - - navigationStackCoordinator.setSheetCoordinator(userIndicatorController) - } - - private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) { - let params = FilePreviewScreenCoordinatorParameters(mediaFile: file, title: title) - let coordinator = FilePreviewScreenCoordinator(parameters: params) - coordinator.callback = { [weak self] _ in - self?.navigationStackCoordinator.pop() - } - - navigationStackCoordinator.push(coordinator) - } - - private func displayEmojiPickerScreen(for itemId: String) { - let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator() - - let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: parameters.emojiProvider, - itemId: itemId) - let coordinator = EmojiPickerScreenCoordinator(parameters: params) - coordinator.callback = { [weak self] action in - switch action { - case let .emojiSelected(emoji: emoji, itemId: itemId): - MXLog.debug("Selected \(emoji) for \(itemId)") - self?.navigationStackCoordinator.setSheetCoordinator(nil) - Task { - await self?.parameters.timelineController.sendReaction(emoji, to: itemId) - } - case .dismiss: - self?.navigationStackCoordinator.setSheetCoordinator(nil) - } - } - - emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator) - emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large] - - navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator) - } - - private func displayRoomDetails() { - let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: parameters.roomProxy, - mediaProvider: parameters.mediaProvider, - userDiscoveryService: parameters.userDiscoveryService) - let coordinator = RoomDetailsScreenCoordinator(parameters: params) - coordinator.callback = { [weak self] action in - switch action { - case .cancel: - self?.navigationStackCoordinator.pop() - case .leftRoom: - self?.callback?(.leftRoom) - } - } - - navigationStackCoordinator.push(coordinator) - } - - private func displayReportContent(for itemID: String, from senderID: String) { - let navigationCoordinator = NavigationStackCoordinator() - let userIndicatorController = UserIndicatorController(rootCoordinator: navigationCoordinator) - let parameters = ReportContentScreenCoordinatorParameters(itemID: itemID, - senderID: senderID, - roomProxy: parameters.roomProxy, - userIndicatorController: userIndicatorController) - let coordinator = ReportContentScreenCoordinator(parameters: parameters) - coordinator.callback = { [weak self] completion in - self?.navigationStackCoordinator.setSheetCoordinator(nil) - switch completion { - case .cancel: break - case .finish: - self?.showSuccess(label: L10n.commonReportSubmitted) - } - } - navigationCoordinator.setRootCoordinator(coordinator) - navigationStackCoordinator.setSheetCoordinator(userIndicatorController) - } - - private func showSuccess(label: String) { - ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark")) - } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 10ed181f7..f1268adbb 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -20,7 +20,7 @@ import UIKit enum RoomScreenViewModelAction { case displayRoomDetails - case displayMediaFile(file: MediaFileHandleProxy, title: String?) + case displayMediaViewer(file: MediaFileHandleProxy, title: String?) case displayEmojiPicker(itemID: String) case displayReportContent(itemID: String, senderID: String) case displayCameraPicker diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 5d833fbeb..f0f66def1 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -154,7 +154,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch action { case .displayMediaFile(let file, let title): - callback?(.displayMediaFile(file: file, title: title)) + callback?(.displayMediaViewer(file: file, title: title)) case .none: break } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 07bf821cc..c9c822e25 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -159,23 +159,19 @@ class MockScreen: Identifiable { return OnboardingCoordinator() case .roomPlainNoAvatar: let navigationStackCoordinator = NavigationStackCoordinator() - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomEncryptedWithAvatar: let navigationStackCoordinator = NavigationStackCoordinator() - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -183,12 +179,10 @@ class MockScreen: Identifiable { let navigationStackCoordinator = NavigationStackCoordinator() let timelineController = MockRoomTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -199,12 +193,10 @@ class MockScreen: Identifiable { timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk] timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -215,12 +207,10 @@ class MockScreen: Identifiable { let timelineController = MockRoomTimelineController(listenForSignals: true) timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -231,12 +221,10 @@ class MockScreen: Identifiable { let timelineController = MockRoomTimelineController(listenForSignals: true) timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -248,12 +236,10 @@ class MockScreen: Identifiable { timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -264,12 +250,10 @@ class MockScreen: Identifiable { let timelineController = MockRoomTimelineController(listenForSignals: true) timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] - let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), + let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), - emojiProvider: EmojiProvider(), - userDiscoveryService: UserDiscoveryServiceMock()) + emojiProvider: EmojiProvider()) let coordinator = RoomScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift new file mode 100644 index 000000000..3387abc11 --- /dev/null +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -0,0 +1,87 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +import Combine +@testable import ElementX + +@MainActor +class RoomFlowCoordinatorTests: XCTestCase { + var roomFlowCoordinator: RoomFlowCoordinator! + var navigationStackCoordinator: NavigationStackCoordinator! + private var cancellables: Set = .init() + + override func setUp() async throws { + let clientProxy = MockClientProxy(userID: "hi@bob", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))) + let mediaProvider = MockMediaProvider() + let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: mediaProvider) + + let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator()) + navigationStackCoordinator = NavigationStackCoordinator() + navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator) + + roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession, + roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), + navigationStackCoordinator: navigationStackCoordinator, + navigationSplitCoordinator: navigationSplitCoordinator, + emojiProvider: EmojiProvider()) + } + + func testRoomPresentation() async { + await process(route: .room(roomID: "1"), expectedAction: .presentedRoom("1")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + + await process(route: .roomList, expectedAction: .dismissedRoom) + XCTAssertNil(navigationStackCoordinator.rootCoordinator) + + await process(route: .room(roomID: "1"), expectedAction: .presentedRoom("1")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + + await process(route: .room(roomID: "2"), expectedAction: .presentedRoom("2")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + + await process(route: .roomList, expectedAction: .dismissedRoom) + XCTAssertNil(navigationStackCoordinator.rootCoordinator) + } + + func testRoomDetailsPresentation() async { + await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator) + + await process(route: .roomList, expectedAction: .dismissedRoom) + XCTAssertNil(navigationStackCoordinator.rootCoordinator) + } + + func testStackUnwinding() async { + await process(route: .roomDetails(roomID: "1"), expectedAction: .presentedRoom("1")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomDetailsScreenCoordinator) + + await process(route: .room(roomID: "2"), expectedAction: .presentedRoom("2")) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + } + + // MARK: - Private + + func process(route: AppRoute, expectedAction: RoomFlowCoordinatorAction) async { + Task { try? await Task.sleep(for: .seconds(0.1)); roomFlowCoordinator.handleAppRoute(route, animated: true) } + _ = await roomFlowCoordinator.actions.values.first(where: { $0 == expectedAction }) + } +} diff --git a/project.yml b/project.yml index 09663bff5..1fd824c62 100644 --- a/project.yml +++ b/project.yml @@ -16,7 +16,7 @@ options: - pattern: ElementX order: [Sources, Resources, SupportingFiles] - pattern: Sources - order: [Application, UserSession, Services, Screens, Other, UITests] + order: [Application, UserSession, Services, FlowCoordinators, Screens, Other, UITests] postGenCommand: cd Tools/XcodeGen && sh postGenCommand.sh settings: