diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index f76a44f17..146ba992f 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -199,10 +199,18 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "HealthChatExtendedProfileSection_Previews") } + func testHealthChatInviteMemberScreen() async throws { + try await performAccessibilityAudit(named: "HealthChatInviteMemberScreen_Previews") + } + func testHealthChatMemberDirectoryScreen() async throws { try await performAccessibilityAudit(named: "HealthChatMemberDirectoryScreen_Previews") } + func testHealthChatProfileEditScreen() async throws { + try await performAccessibilityAudit(named: "HealthChatProfileEditScreen_Previews") + } + func testHighlightedTimelineItemModifier() async throws { try await performAccessibilityAudit(named: "HighlightedTimelineItemModifier_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index c556145a4..a52b5f099 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -146,6 +146,7 @@ 1555A7643D85187D4851040C /* TemplateScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */; }; 1583E2D766E4485FF91662FC /* PermalinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */; }; 15913A5B07118C1268A840E4 /* RoomSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046C0D3F53B0B5EF0A1F5BEA /* RoomSummaryTests.swift */; }; + 1610E4C02793FFC187434A68 /* HealthChatProfileEditScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62950402B9B9B6327D81656 /* HealthChatProfileEditScreenViewModel.swift */; }; 1621BF6316FFFEF5AE067C77 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; }; 1653275750CE11F5CE94DDFD /* ReadReceiptsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8063E65441E771200108C558 /* ReadReceiptsSummaryView.swift */; }; 166D52EC67BD7586050BFB8D /* ContentScannerServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC446234C8E5031F36F1BBE2 /* ContentScannerServiceMock.swift */; }; @@ -363,6 +364,7 @@ 384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEB8D9F4940E161B18FE4BC /* UITestsNotificationCenter.swift */; }; 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */; }; + 388708B57A2212A74385E2FA /* HealthChatProfileEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A616B427F5F5AAF6DC9ED784 /* HealthChatProfileEditScreen.swift */; }; 388D39ED9FE1122EA6D76BF2 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BC84BA0AF11C2128D58ABD /* Common.swift */; }; 3895969759E68FAB90C63EF7 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; 38DE3B3FF9EB8661FD3844CC /* CurrentValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA393404F96DFB505B86E913 /* CurrentValuePublisherTests.swift */; }; @@ -456,6 +458,7 @@ 484202C5D50983442D24D061 /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; 4870F7F72CEBEFDAD1C973A6 /* VoiceMessageMediaEventsTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3521DF92391551E02004D8 /* VoiceMessageMediaEventsTimelineView.swift */; }; 489BB6A733D3DA0FE7062650 /* IdentityConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C71B9802433F1B4252291BB /* IdentityConfirmationScreenViewModelProtocol.swift */; }; + 48CD74299B25EF6034EFF68E /* HealthChatProfileEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF71266558D94B93EC1DD94 /* HealthChatProfileEditScreenViewModelProtocol.swift */; }; 491D62ACD19E6F134B1766AF /* RoomNotificationSettingsUserDefinedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */; }; 492274DA6691EE985C2FCCAA /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; 4940B439681767BE9D78CFDB /* AppLockSetupBiometricsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F5EE5DE3B55D59299DB5BC /* AppLockSetupBiometricsScreenViewModelTests.swift */; }; @@ -496,6 +499,7 @@ 4E0D9E09B52CEC4C0E6211A8 /* MediaPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F49FB9EE2913234F06CE68 /* MediaPickerScreenCoordinator.swift */; }; 4E36A66E0EDA74BF3A036FD0 /* RoomChangeRolesScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AAD8C633AA57948B34EDCF7 /* RoomChangeRolesScreenViewModelProtocol.swift */; }; 4E3F4623592BFEF0D644CEAD /* RoomListActivityVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */; }; + 4E484F993768BADFD0F4B27F /* HealthChatInviteMemberScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD50B3431E44117B6430D5E3 /* HealthChatInviteMemberScreenViewModel.swift */; }; 4E4EF97B9F9CEFAC726BA72F /* TimelineProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EACAFB3F3E017060F9F1C5 /* TimelineProviderMock.swift */; }; 4E8A2A2CFEB212F14E49E1A1 /* AppLockSetupSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484457C81B325660901B161 /* AppLockSetupSettingsScreen.swift */; }; 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; @@ -549,6 +553,7 @@ 5618ED25F092DF5712003829 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; 562EFB9AB62B38830D9AA778 /* TimelineMediaFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933B074F006F8E930DB98B4E /* TimelineMediaFrame.swift */; }; 565868808A1DA565707394ED /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */; }; + 56B162C7ACEEBE4C8257E94C /* HealthChatInviteMemberScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FE9B1BE64CED00EBEF885D /* HealthChatInviteMemberScreenCoordinator.swift */; }; 56C18ACF91DD20463B242460 /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; }; 56DACDD379A86A1F5DEFE7BE /* AuthenticationServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */; }; 56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */; }; @@ -602,6 +607,7 @@ 601AB75BD52B0B4276CEB84A /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161CD412E75F4086F422AE39 /* SessionVerificationScreenStateMachine.swift */; }; 60317E05BFDD46BD99C2871B /* HealthChatMemberDirectoryScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF449911E6776EE3DDE2A34 /* HealthChatMemberDirectoryScreenViewModelTests.swift */; }; 60ACDF0237CED82912786A33 /* test_apple_image.heic in Resources */ = {isa = PBXBuildFile; fileRef = 9C5639C7D679F094E7AB6B04 /* test_apple_image.heic */; }; + 60B4F7D434C520A87927B571 /* HealthChatProfileEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D1B31F1C02FE55411950B68 /* HealthChatProfileEditScreenModels.swift */; }; 60ED66E63A169E47489348A8 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = A20EA00CCB9DBE0FFB17DD09 /* Collections */; }; 611BEE29B8B622204E1E6B04 /* SecurityAndPrivacyScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F2F6B6E56055EF173A2DD3 /* SecurityAndPrivacyScreenCoordinator.swift */; }; 612196AF7826846A50C07FA6 /* SpaceServiceRoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCAA1B97A17A750AC706B112 /* SpaceServiceRoom.swift */; }; @@ -703,6 +709,7 @@ 6DDA932A28F531791BDBFB08 /* HealthChatBrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */; }; 6E03A710799E6C65C0AB36BC /* TargetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D829FD8958376614504B18 /* TargetConfiguration.swift */; }; 6E0C68918BEC5D27A4830BA6 /* PlaybackSpeedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBB8E5436E1BE0D6814BB7D /* PlaybackSpeedButton.swift */; }; + 6E138D8225AD7BECF4BF08BA /* HealthChatProfileEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859E449ECD7E7DAC9926BD8A /* HealthChatProfileEditScreenViewModelTests.swift */; }; 6E391F7F628D984AF44385D9 /* RoomAttachmentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 128501375217576AF0FE3E92 /* RoomAttachmentPicker.swift */; }; 6E44638FDF7D4B0F80EFA7EA /* TraceLogPack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7149BDDE47F8AD104E644E2 /* TraceLogPack.swift */; }; 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */; }; @@ -722,6 +729,7 @@ 7070E5CA0095CBEFAA7DE466 /* MediaEventsTimelineScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F505E3D79018B6EFF953F647 /* MediaEventsTimelineScreenViewModelTests.swift */; }; 707E49BE07E8EB8A13C0EB1E /* SessionVerificationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FACD034DB52525A3CEF2BDF /* SessionVerificationScreen.swift */; }; 708FC3184CCED825F0A36273 /* RoomThreadListServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE06F4A71FD46C9D8CD432E /* RoomThreadListServiceProxy.swift */; }; + 709693B44841045FDBFE905B /* HealthChatProfileEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510951E77FB5FFAC4CE0AB00 /* HealthChatProfileEditScreenCoordinator.swift */; }; 709A9B52FC26B4CB86B8B020 /* EncryptedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */; }; 70B83D44043293B4B77440B9 /* PollFormScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB63761D9F9CE8B23CBD6179 /* PollFormScreenModels.swift */; }; 715FB2D768E8919979EACD71 /* StopButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A786DE47C0DBCE0CD163193 /* StopButton.swift */; }; @@ -730,6 +738,7 @@ 71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90514BE9B8ACCBCF0AD2489 /* ComposerToolbarViewModel.swift */; }; 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; }; 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; + 72031674925CD5CC448FA060 /* HealthChatInviteMemberScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F41D7A77A842BB87455D799 /* HealthChatInviteMemberScreenViewModelTests.swift */; }; 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */; }; 7254FB2EFDD43BC8BB7A1213 /* SecurityAndPrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AE42C19EDE64B7CB7BE4D0 /* SecurityAndPrivacyScreen.swift */; }; 726AA74DF4E5EFCEBD78CE3F /* RoomMembersFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */; }; @@ -899,9 +908,11 @@ 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */; }; 8AC8FDECC74D710CA97A2991 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C6AE2DB4FE7FAEA6402663 /* Secrets.swift */; }; 8AEE47A14223423806A7653A /* LiveLocationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */; }; + 8B1325C12F606B14FAA666C9 /* UserDetailsEditScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5CC7FB1CF8C69041B62DD7 /* UserDetailsEditScreenHook.swift */; }; 8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; }; 8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; }; 8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; }; + 8B47CFEEF576711A8A254A82 /* HealthChatInviteMemberScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D288E21923FFC49D1CF246B /* HealthChatInviteMemberScreenViewModelProtocol.swift */; }; 8B6ED1393F94AEEFAE74C8BB /* PresenceServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */; }; 8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; 8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; }; @@ -1347,6 +1358,7 @@ D16B3134A7FF4FBA0749014A /* AuthenticationClassicAppAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */; }; D18B70975644C24F60656C0D /* KnockRequestProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */; }; D19A748E95E2FAB2940570F0 /* CallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4103AB4340F2974D690A12A /* CallScreen.swift */; }; + D1A0D9EBE3B9BF4E76DE309A /* HealthChatUserDetailsEditScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA14BE0D369AD8098C93F264 /* HealthChatUserDetailsEditScreenHook.swift */; }; D1C942421F607285C50FB792 /* SearchServiceSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF5721148A5D59B476BA976 /* SearchServiceSDKMock.swift */; }; D2048FD56760BDABA3DB5FC2 /* AppLockServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EAAB54C6CE91D64B69A9F8 /* AppLockServiceProtocol.swift */; }; D22345698F6548C1EE960940 /* IdentityConfirmedScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DBE70FFB7936F35811772C1 /* IdentityConfirmedScreenModels.swift */; }; @@ -1433,6 +1445,7 @@ E1C67E5D9E22135A8FEBBD60 /* StackedAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8558D41DD4B553A752C868A /* StackedAvatarsView.swift */; }; E1DE8A5422643F759385290F /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD5523BDEDB247E29228476 /* AppSettings.swift */; }; E1DF24D085572A55C9758A2D /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; }; + E1FBD9A227E6AF535D6ECACE /* HealthChatInviteMemberScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A982A9C85153CB4BCA78373 /* HealthChatInviteMemberScreenModels.swift */; }; E26E702F325F0AA49B8A5CFD /* RoomPowerLevelsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */; }; E27C4D1A1F8BB77CA790B403 /* InviteUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */; }; E290C78E7F09F47FD2662986 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; @@ -1535,6 +1548,7 @@ EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */; }; EFC4CCCF9D22FFA7D542693A /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; }; EFF735EC040BEB669AFBAB50 /* LeaveSpaceHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */; }; + F0168B8B3D23CEE946188021 /* HealthChatInviteMemberScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427E1815BFBDBF9CFE22D739 /* HealthChatInviteMemberScreen.swift */; }; F0570F1ECD70C4C851FB2052 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E7304F5ECB4CB11CB10E60 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift */; }; F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; }; F07D88421A9BC4D03D4A5055 /* VideoRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */; }; @@ -1959,6 +1973,7 @@ 1C7F63EB1525E697CAEB002B /* BlankFormCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankFormCoordinator.swift; sourceTree = ""; }; 1CC575D1895FA62591451A93 /* RoomMemberDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreen.swift; sourceTree = ""; }; 1CD7C0A2750998C2D77AD00F /* JoinRoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRoomScreenViewModel.swift; sourceTree = ""; }; + 1CF71266558D94B93EC1DD94 /* HealthChatProfileEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreenViewModelProtocol.swift; sourceTree = ""; }; 1D262A26713C18BB70C82CA5 /* MapTilerURLBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTilerURLBuilderTests.swift; sourceTree = ""; }; 1D56469A9EE0CFA2B7BA9760 /* SessionVerificationControllerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxyProtocol.swift; sourceTree = ""; }; 1D652E78832289CD9EB64488 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2071,6 +2086,7 @@ 2E11E7C396ED06A154CF6DF3 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/SAS.strings; sourceTree = ""; }; 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineView.swift; sourceTree = ""; }; 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; + 2F41D7A77A842BB87455D799 /* HealthChatInviteMemberScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreenViewModelTests.swift; sourceTree = ""; }; 2F65178687F725E8924B723C /* TimelineFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFixtures.swift; sourceTree = ""; }; 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventContent.swift; sourceTree = ""; }; 2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClockTests.swift; sourceTree = ""; }; @@ -2181,6 +2197,7 @@ 421E716C521F96D24ECE69B3 /* NoticeRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItem.swift; sourceTree = ""; }; 421FA93BCC2840E66E4F306F /* NotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLayoutLabelStyle.swift; sourceTree = ""; }; + 427E1815BFBDBF9CFE22D739 /* HealthChatInviteMemberScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreen.swift; sourceTree = ""; }; 42C58159916ABD7F454CE689 /* AdvancedSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenCoordinator.swift; sourceTree = ""; }; 42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenModels.swift; sourceTree = ""; }; 42C8C368A611B9CB79C7F5FA /* RoomPollsHistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreen.swift; sourceTree = ""; }; @@ -2249,6 +2266,7 @@ 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; 4CF17EFB2833B4CE5C06E7F8 /* LabsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenCoordinator.swift; sourceTree = ""; }; + 4D1B31F1C02FE55411950B68 /* HealthChatProfileEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreenModels.swift; sourceTree = ""; }; 4D3A7375AB22721C436EB056 /* ComposerToolbarModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarModels.swift; sourceTree = ""; }; 4D57D1C26306256332BA070F /* ClassicAppMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMediaLoader.swift; sourceTree = ""; }; 4D635709C1D6D37C225AD40E /* RoomPowerLevelProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelProxyProtocol.swift; sourceTree = ""; }; @@ -2268,6 +2286,7 @@ 505ADA084C0B38A0C4AD2574 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineItem.swift; sourceTree = ""; }; 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixUserShareLink.swift; sourceTree = ""; }; + 510951E77FB5FFAC4CE0AB00 /* HealthChatProfileEditScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreenCoordinator.swift; sourceTree = ""; }; 510DA63AB273A68D659CEC95 /* ToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButton.swift; sourceTree = ""; }; 510E89B989477E5EE8E503C0 /* PinnedEventsTimelineScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenViewModelProtocol.swift; sourceTree = ""; }; 514363244AE7D68080D44C6F /* NotificationSettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenViewModelTests.swift; sourceTree = ""; }; @@ -2526,6 +2545,7 @@ 7CF4BC6D41585235BDFF4DE6 /* TextFieldAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldAdapter.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 = ""; }; + 7D288E21923FFC49D1CF246B /* HealthChatInviteMemberScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreenViewModelProtocol.swift; sourceTree = ""; }; 7D39AF1F659923D77778511E /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; 7DB2A5FF1E68BA580A20D405 /* SpaceRoomListProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomListProxyMock.swift; sourceTree = ""; }; 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenViewModel.swift; sourceTree = ""; }; @@ -2588,6 +2608,7 @@ 8585C636A10B8141A7AE909F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = ""; }; 858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockedRoomProxy.swift; sourceTree = ""; }; 858F8D0B0D51CC41BAA18E24 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 859E449ECD7E7DAC9926BD8A /* HealthChatProfileEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreenViewModelTests.swift; sourceTree = ""; }; 859F51637DA710BBE7B70D6D /* ChatsSpaceFiltersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatsSpaceFiltersScreenViewModel.swift; sourceTree = ""; }; 85E8591F06B2DAA284181308 /* HomeserverCapabilitiesProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeserverCapabilitiesProxyProtocol.swift; sourceTree = ""; }; 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = ""; }; @@ -2708,6 +2729,7 @@ 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModule.swift; sourceTree = ""; }; 9A34D9BCA1A7D9A56E1EAF1D /* ManageRoomMemberSheetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetViewModel.swift; sourceTree = ""; }; 9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = ""; }; + 9A982A9C85153CB4BCA78373 /* HealthChatInviteMemberScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreenModels.swift; sourceTree = ""; }; 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemFactoryTests.swift; sourceTree = ""; }; 9B3711C137F33EC1F8BEAB10 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/HealthChat.strings; sourceTree = ""; }; 9B6572E6EF5D5F4B0C338A40 /* PinnedEventsTimelineScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineScreenModels.swift; sourceTree = ""; }; @@ -2775,6 +2797,7 @@ A566F8F2C27B99E1FB80C69B /* JoinRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRule.swift; sourceTree = ""; }; A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClassicAppAccountView.swift; sourceTree = ""; }; A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListActivityVisibility.swift; sourceTree = ""; }; + A616B427F5F5AAF6DC9ED784 /* HealthChatProfileEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreen.swift; sourceTree = ""; }; A6B19D10B102956066AF117B /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriverProtocol.swift; sourceTree = ""; }; @@ -2925,6 +2948,7 @@ BC2E7CEE6E9314FD69FE0ED9 /* ThreadTimelineScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTimelineScreen.swift; sourceTree = ""; }; BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = ""; }; BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemDebugView.swift; sourceTree = ""; }; + BD50B3431E44117B6430D5E3 /* HealthChatInviteMemberScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreenViewModel.swift; sourceTree = ""; }; BD5480F03306234FC086E93B /* HomeScreenNewSoundBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenNewSoundBanner.swift; sourceTree = ""; }; BDB0DD6E869B4764958CD7AB /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = ""; }; BDBB8E5436E1BE0D6814BB7D /* PlaybackSpeedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSpeedButton.swift; sourceTree = ""; }; @@ -3002,6 +3026,7 @@ C9E535B3388755B65C34CD10 /* UnsupportedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineView.swift; sourceTree = ""; }; C9E8A8EC299E12490588B07C /* KnockRequestsListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreenCoordinator.swift; sourceTree = ""; }; C9F893F4A111CB7BA5C96949 /* AppLockSetupBiometricsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModel.swift; sourceTree = ""; }; + CA14BE0D369AD8098C93F264 /* HealthChatUserDetailsEditScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatUserDetailsEditScreenHook.swift; sourceTree = ""; }; CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = ""; }; CA29952595B804DA221A0C1D /* ComposerToolbarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarViewModelTests.swift; sourceTree = ""; }; CA46BDF38F87118939BDF659 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3050,6 +3075,7 @@ D1BC84BA0AF11C2128D58ABD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; D1D8479BB704B7EF696F8ABE /* RoomPollsHistoryScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenCoordinator.swift; sourceTree = ""; }; D1D97BAF04AA150C0EF03021 /* VerificationBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationBadge.swift; sourceTree = ""; }; + D1FE9B1BE64CED00EBEF885D /* HealthChatInviteMemberScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatInviteMemberScreenCoordinator.swift; sourceTree = ""; }; D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = ""; }; D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFlowCoordinator.swift; sourceTree = ""; }; D2B0E0A2F16603E7C827C295 /* AuthenticationClassicAppBackupInstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClassicAppBackupInstructionsView.swift; sourceTree = ""; }; @@ -3072,6 +3098,7 @@ D5D186A6DB8FAC5C9D0E4D61 /* RemoteSettingsHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsHook.swift; sourceTree = ""; }; D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionServiceTests.swift; sourceTree = ""; }; D622EC7898469BB1D0881CDD /* PollFormScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreen.swift; sourceTree = ""; }; + D62950402B9B9B6327D81656 /* HealthChatProfileEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatProfileEditScreenViewModel.swift; sourceTree = ""; }; D653265D006E708E4E51AD64 /* HomeScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenCoordinator.swift; sourceTree = ""; }; D66B5D86A9AB95E0E01BED82 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; D67CBAFA48ED0B6FCE74F88F /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/Localizable.strings; sourceTree = ""; }; @@ -3102,6 +3129,7 @@ DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSession.swift; sourceTree = ""; }; DA3D82522494E78746B2214E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SAS.strings; sourceTree = ""; }; DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLLocationManagerProtocol.swift; sourceTree = ""; }; + DA5CC7FB1CF8C69041B62DD7 /* UserDetailsEditScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenHook.swift; sourceTree = ""; }; DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCache.swift; sourceTree = ""; }; DADECBBB672497BCD4822468 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = ""; }; @@ -3970,6 +3998,14 @@ path = View; sourceTree = ""; }; + 2B663A61F6020B616B744B97 /* View */ = { + isa = PBXGroup; + children = ( + 427E1815BFBDBF9CFE22D739 /* HealthChatInviteMemberScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 2C0F49BD446849654C0D24E0 /* RoomMember */ = { isa = PBXGroup; children = ( @@ -4041,6 +4077,7 @@ 99EB08B2C8B01F3BFE4B0CEC /* RoomMemberDetailsScreenHook.swift */, B343C5255FB408DDE853CFDF /* RoomScreenHook.swift */, 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */, + DA5CC7FB1CF8C69041B62DD7 /* UserDetailsEditScreenHook.swift */, 8B35734F3743563566413C9A /* UserProfileScreenHook.swift */, E9773983346838AABFAA914D /* UserSessionHook.swift */, ); @@ -4737,6 +4774,18 @@ path = UnitTests; sourceTree = ""; }; + 4D14D6CC178E3F988E7B293D /* HealthChatInviteMemberScreen */ = { + isa = PBXGroup; + children = ( + D1FE9B1BE64CED00EBEF885D /* HealthChatInviteMemberScreenCoordinator.swift */, + 9A982A9C85153CB4BCA78373 /* HealthChatInviteMemberScreenModels.swift */, + BD50B3431E44117B6430D5E3 /* HealthChatInviteMemberScreenViewModel.swift */, + 7D288E21923FFC49D1CF246B /* HealthChatInviteMemberScreenViewModelProtocol.swift */, + 2B663A61F6020B616B744B97 /* View */, + ); + path = HealthChatInviteMemberScreen; + sourceTree = ""; + }; 4D963F50D7AA8FE302CA8ACF /* LabsScreen */ = { isa = PBXGroup; children = ( @@ -5665,6 +5714,7 @@ AD3C983BFE38789B9C5EA7A5 /* HealthChatExtendedProfileSectionHook.swift */, 9EDD488380158986A75D6640 /* HealthChatHomeTabsHook.swift */, 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */, + CA14BE0D369AD8098C93F264 /* HealthChatUserDetailsEditScreenHook.swift */, ); path = HealthChat; sourceTree = ""; @@ -5774,6 +5824,14 @@ path = View; sourceTree = ""; }; + 835516AC5136333384C6D963 /* View */ = { + isa = PBXGroup; + children = ( + A616B427F5F5AAF6DC9ED784 /* HealthChatProfileEditScreen.swift */, + ); + path = View; + sourceTree = ""; + }; 8428BD1821586CEE0B9C9ECA /* Messages */ = { isa = PBXGroup; children = ( @@ -5809,8 +5867,10 @@ CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */, 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */, BB7ABDCA2059497899F226DF /* HealthChatExtendedProfileLoaderTests.swift */, + 2F41D7A77A842BB87455D799 /* HealthChatInviteMemberScreenViewModelTests.swift */, 7DF449911E6776EE3DDE2A34 /* HealthChatMemberDirectoryScreenViewModelTests.swift */, 11E3983406311A8BBE37A19A /* HealthChatOpenIDTokenProviderTests.swift */, + 859E449ECD7E7DAC9926BD8A /* HealthChatProfileEditScreenViewModelTests.swift */, ); path = HealthChat; sourceTree = ""; @@ -6607,6 +6667,18 @@ path = RoomMemberDetailsScreen; sourceTree = ""; }; + B8B034AEEFD1AC910F5B1DD9 /* HealthChatProfileEditScreen */ = { + isa = PBXGroup; + children = ( + 510951E77FB5FFAC4CE0AB00 /* HealthChatProfileEditScreenCoordinator.swift */, + 4D1B31F1C02FE55411950B68 /* HealthChatProfileEditScreenModels.swift */, + D62950402B9B9B6327D81656 /* HealthChatProfileEditScreenViewModel.swift */, + 1CF71266558D94B93EC1DD94 /* HealthChatProfileEditScreenViewModelProtocol.swift */, + 835516AC5136333384C6D963 /* View */, + ); + path = HealthChatProfileEditScreen; + sourceTree = ""; + }; BB11CF5847CA0B3BC3A7F8DA /* View */ = { isa = PBXGroup; children = ( @@ -7161,7 +7233,9 @@ F5A65D1D3B83593598DC278D /* EmojiPickerScreen */, 8656AFF06650360A5D0695FF /* EncryptionReset */, 448435400B561C40E514BE1C /* FilePreviewScreen */, + 4D14D6CC178E3F988E7B293D /* HealthChatInviteMemberScreen */, 87188EFD40C301F153B6572E /* HealthChatMemberDirectoryScreen */, + B8B034AEEFD1AC910F5B1DD9 /* HealthChatProfileEditScreen */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, F12966DF3DA87FEF21348D60 /* InviteUsersScreen */, FFD7C58CA6A7D6BBC2F584B5 /* JoinRoomScreen */, @@ -8454,8 +8528,10 @@ C40957497CAFADB846784A2A /* HealthChatAPIModelsTests.swift in Sources */, F995036BBA39691CF7AD1F08 /* HealthChatAPIServiceTests.swift in Sources */, 99DE2B618703A58BC3303B92 /* HealthChatExtendedProfileLoaderTests.swift in Sources */, + 72031674925CD5CC448FA060 /* HealthChatInviteMemberScreenViewModelTests.swift in Sources */, 60317E05BFDD46BD99C2871B /* HealthChatMemberDirectoryScreenViewModelTests.swift in Sources */, BC38C35A60F164A7AE3743E4 /* HealthChatOpenIDTokenProviderTests.swift in Sources */, + 6E138D8225AD7BECF4BF08BA /* HealthChatProfileEditScreenViewModelTests.swift in Sources */, 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */, F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */, 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */, @@ -9004,6 +9080,11 @@ 630CC29D639086CB1F88C74E /* HealthChatExtendedProfileSection.swift in Sources */, FAA41D6957FC5BA357092D6D /* HealthChatExtendedProfileSectionHook.swift in Sources */, 9A94A0FED05B52019B9CCD5B /* HealthChatHomeTabsHook.swift in Sources */, + F0168B8B3D23CEE946188021 /* HealthChatInviteMemberScreen.swift in Sources */, + 56B162C7ACEEBE4C8257E94C /* HealthChatInviteMemberScreenCoordinator.swift in Sources */, + E1FBD9A227E6AF535D6ECACE /* HealthChatInviteMemberScreenModels.swift in Sources */, + 4E484F993768BADFD0F4B27F /* HealthChatInviteMemberScreenViewModel.swift in Sources */, + 8B47CFEEF576711A8A254A82 /* HealthChatInviteMemberScreenViewModelProtocol.swift in Sources */, 3A053CFE1CF7129E4C5E05BE /* HealthChatMemberDirectoryScreen.swift in Sources */, 392853E45693680A8B1FF224 /* HealthChatMemberDirectoryScreenCoordinator.swift in Sources */, 10C8D33023FE3628B58DA459 /* HealthChatMemberDirectoryScreenModels.swift in Sources */, @@ -9012,7 +9093,13 @@ 2768E28BC1A92DCCF4A82BEF /* HealthChatMemberListRow.swift in Sources */, F24F8DFA140C94C9A815FE8C /* HealthChatMembersFlowCoordinator.swift in Sources */, 4B5E2E8FBE54B73F79E607BC /* HealthChatOpenIDTokenProvider.swift in Sources */, + 388708B57A2212A74385E2FA /* HealthChatProfileEditScreen.swift in Sources */, + 709693B44841045FDBFE905B /* HealthChatProfileEditScreenCoordinator.swift in Sources */, + 60B4F7D434C520A87927B571 /* HealthChatProfileEditScreenModels.swift in Sources */, + 1610E4C02793FFC187434A68 /* HealthChatProfileEditScreenViewModel.swift in Sources */, + 48CD74299B25EF6034EFF68E /* HealthChatProfileEditScreenViewModelProtocol.swift in Sources */, 1E822B072359DD37EEA8CE78 /* HealthChatSettings.swift in Sources */, + D1A0D9EBE3B9BF4E76DE309A /* HealthChatUserDetailsEditScreenHook.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */, @@ -9750,6 +9837,7 @@ 8CFB8BB99F25FA34B6C6BE86 /* UserDefaultsProtocol.swift in Sources */, 7D58B4F46CAA9A7C3E4C6A30 /* UserDetailsEditScreen.swift in Sources */, A64B52D9F73F9A6B95AF24FE /* UserDetailsEditScreenCoordinator.swift in Sources */, + 8B1325C12F606B14FAA666C9 /* UserDetailsEditScreenHook.swift in Sources */, 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */, 4BB51476A29E7E27BC14EA22 /* UserDetailsEditScreenViewModel.swift in Sources */, 0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */, diff --git a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings index 1852f5379..76e3c124a 100644 --- a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings @@ -3,10 +3,25 @@ "common_address" = "Adresse"; "common_business_email" = "E-Mail (geschäftlich)"; "common_business_tel" = "Telefon (geschäftlich)"; +"common_city" = "Ort"; +"common_first_name" = "Vorname"; "common_further_info" = "Weitere Infos / Qualifikationen"; +"common_house_number" = "Nr."; +"common_last_name" = "Nachname"; +"common_postal_code" = "PLZ"; "common_practice_name" = "Praxisname / Firmenname"; "common_specialty" = "Fachgebiet"; +"common_street" = "Strasse"; "common_website" = "Webseite"; +"screen_invite_member_email_placeholder" = "E-Mail-Adresse"; +"screen_invite_member_note_footer" = "Die persönliche Nachricht muss mindestens 10 Zeichen lang sein."; +"screen_invite_member_note_placeholder" = "Persönliche Nachricht"; +"screen_invite_member_success" = "Einladung wurde versendet"; +"screen_invite_member_title" = "Mitglied einladen"; "screen_member_directory_pending_approval" = "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde."; "screen_member_directory_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet"; "screen_member_directory_title" = "Mitglieder"; +"screen_profile_edit_section_address" = "Praxisadresse"; +"screen_profile_edit_section_contact" = "Kontaktdaten"; +"screen_profile_edit_section_personal" = "Persönliche Angaben"; +"screen_profile_edit_section_professional" = "Berufliche Angaben"; diff --git a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings index 9ca42e318..90c11a769 100644 --- a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings @@ -3,10 +3,25 @@ "common_address" = "Address"; "common_business_email" = "Business email"; "common_business_tel" = "Business phone"; +"common_city" = "City"; +"common_first_name" = "First name"; "common_further_info" = "Further infos / qualifications"; +"common_house_number" = "No."; +"common_last_name" = "Last name"; +"common_postal_code" = "Postal code"; "common_practice_name" = "Practice name"; "common_specialty" = "Speciality"; +"common_street" = "Street"; "common_website" = "Website"; +"screen_invite_member_email_placeholder" = "Email address"; +"screen_invite_member_note_footer" = "The personal message must be at least 10 characters long."; +"screen_invite_member_note_placeholder" = "Personal message"; +"screen_invite_member_success" = "Invitation sent"; +"screen_invite_member_title" = "Invite a member"; "screen_member_directory_pending_approval" = "Your account is being reviewed. You’ll get access to the member directory once it has been approved."; "screen_member_directory_search_placeholder" = "Name, practice, city or speciality"; "screen_member_directory_title" = "Members"; +"screen_profile_edit_section_address" = "Practice address"; +"screen_profile_edit_section_contact" = "Contact details"; +"screen_profile_edit_section_personal" = "Personal details"; +"screen_profile_edit_section_professional" = "Professional details"; diff --git a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings index ac1b46558..7028bb30b 100644 --- a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings @@ -3,10 +3,25 @@ "common_address" = "Adresse"; "common_business_email" = "Email (professionnel)"; "common_business_tel" = "Téléphone (professionnel)"; +"common_city" = "Ville"; +"common_first_name" = "Prénom"; "common_further_info" = "Informations supplémentaires / qualifications"; +"common_house_number" = "N°"; +"common_last_name" = "Nom"; +"common_postal_code" = "Code postal"; "common_practice_name" = "Nom de l’entreprise"; "common_specialty" = "Spécialisation"; +"common_street" = "Rue"; "common_website" = "Site web"; +"screen_invite_member_email_placeholder" = "Adresse email"; +"screen_invite_member_note_footer" = "Le message personnel doit contenir au moins 10 caractères."; +"screen_invite_member_note_placeholder" = "Message personnel"; +"screen_invite_member_success" = "Invitation envoyée"; +"screen_invite_member_title" = "Inviter un membre"; "screen_member_directory_pending_approval" = "Votre compte est en cours de vérification. Vous aurez accès à l’annuaire des membres dès qu’il aura été approuvé."; "screen_member_directory_search_placeholder" = "Nom, cabinet, ville ou spécialisation"; "screen_member_directory_title" = "Membres"; +"screen_profile_edit_section_address" = "Adresse du cabinet"; +"screen_profile_edit_section_contact" = "Coordonnées"; +"screen_profile_edit_section_personal" = "Informations personnelles"; +"screen_profile_edit_section_professional" = "Informations professionnelles"; diff --git a/ElementX/Sources/AppHooks/AppHooks.swift b/ElementX/Sources/AppHooks/AppHooks.swift index b196883cd..1e33ae477 100644 --- a/ElementX/Sources/AppHooks/AppHooks.swift +++ b/ElementX/Sources/AppHooks/AppHooks.swift @@ -49,6 +49,9 @@ final nonisolated class AppHooks: AppHooksProtocol { @AppHook(default: DefaultHomeTabsHook()) var homeTabsHook: HomeTabsHookProtocol + + @AppHook(default: DefaultUserDetailsEditScreenHook()) + var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol @AppHook(default: DefaultDeveloperOptionsScreenHook()) var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift index 167e71133..59de8cae9 100644 --- a/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift @@ -18,6 +18,7 @@ extension AppHooks { registerCompoundHook(HealthChatCompoundHook()) registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook()) registerHomeTabsHook(HealthChatHomeTabsHook()) + registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook()) #endif } } diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatUserDetailsEditScreenHook.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatUserDetailsEditScreenHook.swift new file mode 100644 index 000000000..446f6f49f --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatUserDetailsEditScreenHook.swift @@ -0,0 +1,26 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +#if IS_MAIN_APP +/// Replaces the stock display-name/avatar editor with the HealthChat profile editor. +/// The display name is derived server-side from first and last name, so the stock +/// editor would let users create values the next profile update overwrites. +struct HealthChatUserDetailsEditScreenHook: UserDetailsEditScreenHookProtocol { + @MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol { + let clientProxy = parameters.userSession.clientProxy + let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: clientProxy)) + return HealthChatProfileEditScreenCoordinator(parameters: .init(orientationManager: parameters.orientationManager, + userSession: parameters.userSession, + apiService: apiService, + mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor, + navigationStackCoordinator: parameters.navigationStackCoordinator, + userIndicatorController: parameters.userIndicatorController)) + } +} +#endif diff --git a/ElementX/Sources/AppHooks/Hooks/UserDetailsEditScreenHook.swift b/ElementX/Sources/AppHooks/Hooks/UserDetailsEditScreenHook.swift new file mode 100644 index 000000000..4910d3222 --- /dev/null +++ b/ElementX/Sources/AppHooks/Hooks/UserDetailsEditScreenHook.swift @@ -0,0 +1,25 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +@MainActor protocol UserDetailsEditCoordinatorProtocol: CoordinatorProtocol { + var actions: AnyPublisher { get } +} + +nonisolated protocol UserDetailsEditScreenHookProtocol: Sendable { + @MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol +} + +struct DefaultUserDetailsEditScreenHook: UserDetailsEditScreenHookProtocol { + @MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol { + UserDetailsEditScreenCoordinator(parameters: parameters) + } +} + +extension UserDetailsEditScreenCoordinator: UserDetailsEditCoordinatorProtocol { } diff --git a/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift index 925570ac0..32917a5bb 100644 --- a/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/HealthChatMembersFlowCoordinator.swift @@ -9,10 +9,12 @@ import Combine import SwiftState import SwiftUI -/// The flow behind the HealthChat "Members" tab: the member directory with -/// pushed member profiles. Chats and calls are handed off to the session flow. +/// The flow behind the HealthChat "Members" tab: the member directory with pushed member +/// profiles, the own-profile editor and the invitation sheet. Chats and calls are handed +/// off to the session flow. final class HealthChatMembersFlowCoordinator { private let flowParameters: CommonFlowParameters + private let apiService: HealthChatAPIServiceProtocol let navigationStackCoordinator: NavigationStackCoordinator @@ -25,6 +27,10 @@ final class HealthChatMembersFlowCoordinator { case memberDirectory /// A member's profile pushed on top of the directory. case memberProfile + /// The own-profile editor pushed on top of the directory. + case profileEditor + /// The invitation sheet presented over the directory. + case inviteSheet } enum Event: EventType { @@ -34,6 +40,14 @@ final class HealthChatMembersFlowCoordinator { case showMemberProfile /// The member profile has been dismissed. case dismissedMemberProfile + /// Request presentation of the own-profile editor. + case showProfileEditor + /// The profile editor has been dismissed. + case dismissedProfileEditor + /// Request presentation of the invitation sheet. + case showInviteSheet + /// The invitation sheet has been dismissed. + case dismissedInviteSheet } private let stateMachine: StateMachine @@ -45,6 +59,7 @@ final class HealthChatMembersFlowCoordinator { init(flowParameters: CommonFlowParameters) { self.flowParameters = flowParameters + apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: flowParameters.userSession.clientProxy)) navigationStackCoordinator = NavigationStackCoordinator() stateMachine = StateMachine(state: .initial) @@ -64,9 +79,14 @@ final class HealthChatMembersFlowCoordinator { } stateMachine.addRoutes(event: .showMemberProfile, transitions: [.memberDirectory => .memberProfile]) - stateMachine.addRoutes(event: .dismissedMemberProfile, transitions: [.memberProfile => .memberDirectory]) + stateMachine.addRoutes(event: .showProfileEditor, transitions: [.memberDirectory => .profileEditor]) + stateMachine.addRoutes(event: .dismissedProfileEditor, transitions: [.profileEditor => .memberDirectory]) + + stateMachine.addRoutes(event: .showInviteSheet, transitions: [.memberDirectory => .inviteSheet]) + stateMachine.addRoutes(event: .dismissedInviteSheet, transitions: [.inviteSheet => .memberDirectory]) + stateMachine.addErrorHandler { context in fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` for event `\(String(describing: context.event))`.") } @@ -74,7 +94,6 @@ final class HealthChatMembersFlowCoordinator { private func presentMemberDirectory() { let userSession = flowParameters.userSession - let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: userSession.clientProxy)) let coordinator = HealthChatMemberDirectoryScreenCoordinator(parameters: .init(apiService: apiService, clientProxy: userSession.clientProxy, mediaProvider: userSession.mediaProvider, @@ -87,6 +106,10 @@ final class HealthChatMembersFlowCoordinator { presentMemberProfile(userID: userID) case .presentRoom(let roomID): actionsSubject.send(.presentRoom(roomID: roomID)) + case .editProfile: + presentProfileEditor() + case .invite: + presentInviteSheet() } } .store(in: &cancellables) @@ -122,4 +145,48 @@ final class HealthChatMembersFlowCoordinator { self?.stateMachine.tryEvent(.dismissedMemberProfile) } } + + private func presentProfileEditor() { + let parameters = UserDetailsEditScreenCoordinatorParameters(orientationManager: flowParameters.windowManager, + userSession: flowParameters.userSession, + mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), + navigationStackCoordinator: navigationStackCoordinator, + userIndicatorController: flowParameters.userIndicatorController, + appSettings: flowParameters.appSettings) + let coordinator = flowParameters.appHooks.userDetailsEditScreenHook.makeCoordinator(parameters: parameters) + + coordinator.actions.sink { [weak self] action in + switch action { + case .dismiss: + self?.navigationStackCoordinator.pop() + } + } + .store(in: &cancellables) + + stateMachine.tryEvent(.showProfileEditor) + navigationStackCoordinator.push(coordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedProfileEditor) + } + } + + private func presentInviteSheet() { + let stackCoordinator = NavigationStackCoordinator() + let coordinator = HealthChatInviteMemberScreenCoordinator(parameters: .init(apiService: apiService, + userIndicatorController: flowParameters.userIndicatorController)) + + coordinator.actionsPublisher.sink { [weak self] action in + switch action { + case .dismiss: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + stackCoordinator.setRootCoordinator(coordinator) + + stateMachine.tryEvent(.showInviteSheet) + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedInviteSheet) + } + } } diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index c8c1c6a64..0ddeaffef 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -179,11 +179,12 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { } private func presentUserDetailsEditScreen() { - let coordinator = UserDetailsEditScreenCoordinator(parameters: .init(orientationManager: flowParameters.windowManager, - userSession: flowParameters.userSession, - mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), - navigationStackCoordinator: navigationStackCoordinator, - userIndicatorController: flowParameters.userIndicatorController)) + let coordinator = flowParameters.appHooks.userDetailsEditScreenHook + .makeCoordinator(parameters: .init(orientationManager: flowParameters.windowManager, + userSession: flowParameters.userSession, + mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), + navigationStackCoordinator: navigationStackCoordinator, + userIndicatorController: flowParameters.userIndicatorController)) coordinator.actions .sink { [weak self] action in switch action { diff --git a/ElementX/Sources/Generated/Strings+HealthChat.swift b/ElementX/Sources/Generated/Strings+HealthChat.swift index 21e337bcf..3e528425a 100644 --- a/ElementX/Sources/Generated/Strings+HealthChat.swift +++ b/ElementX/Sources/Generated/Strings+HealthChat.swift @@ -18,20 +18,50 @@ internal nonisolated enum HealthChatL10n { internal static var commonBusinessEmail: String { return HealthChatL10n.tr("HealthChat", "common_business_email") } /// Business phone internal static var commonBusinessTel: String { return HealthChatL10n.tr("HealthChat", "common_business_tel") } + /// City + internal static var commonCity: String { return HealthChatL10n.tr("HealthChat", "common_city") } + /// First name + internal static var commonFirstName: String { return HealthChatL10n.tr("HealthChat", "common_first_name") } /// Further infos / qualifications internal static var commonFurtherInfo: String { return HealthChatL10n.tr("HealthChat", "common_further_info") } + /// No. + internal static var commonHouseNumber: String { return HealthChatL10n.tr("HealthChat", "common_house_number") } + /// Last name + internal static var commonLastName: String { return HealthChatL10n.tr("HealthChat", "common_last_name") } + /// Postal code + internal static var commonPostalCode: String { return HealthChatL10n.tr("HealthChat", "common_postal_code") } /// Practice name internal static var commonPracticeName: String { return HealthChatL10n.tr("HealthChat", "common_practice_name") } /// Speciality internal static var commonSpecialty: String { return HealthChatL10n.tr("HealthChat", "common_specialty") } + /// Street + internal static var commonStreet: String { return HealthChatL10n.tr("HealthChat", "common_street") } /// Website internal static var commonWebsite: String { return HealthChatL10n.tr("HealthChat", "common_website") } + /// Email address + internal static var screenInviteMemberEmailPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_email_placeholder") } + /// The personal message must be at least 10 characters long. + internal static var screenInviteMemberNoteFooter: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_note_footer") } + /// Personal message + internal static var screenInviteMemberNotePlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_note_placeholder") } + /// Invitation sent + internal static var screenInviteMemberSuccess: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_success") } + /// Invite a member + internal static var screenInviteMemberTitle: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_title") } /// Your account is being reviewed. You’ll get access to the member directory once it has been approved. internal static var screenMemberDirectoryPendingApproval: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_approval") } /// Name, practice, city or speciality internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") } /// Members internal static var screenMemberDirectoryTitle: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_title") } + /// Practice address + internal static var screenProfileEditSectionAddress: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_address") } + /// Contact details + internal static var screenProfileEditSectionContact: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_contact") } + /// Personal details + internal static var screenProfileEditSectionPersonal: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_personal") } + /// Professional details + internal static var screenProfileEditSectionProfessional: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_professional") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 6dd112cff..c6a521956 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -57,7 +57,9 @@ enum TestablePreviewsDictionary { "FullscreenDialog_Previews" : FullscreenDialog_Previews.self, "GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self, "HealthChatExtendedProfileSection_Previews" : HealthChatExtendedProfileSection_Previews.self, + "HealthChatInviteMemberScreen_Previews" : HealthChatInviteMemberScreen_Previews.self, "HealthChatMemberDirectoryScreen_Previews" : HealthChatMemberDirectoryScreen_Previews.self, + "HealthChatProfileEditScreen_Previews" : HealthChatProfileEditScreen_Previews.self, "HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self, "HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self, "HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self, diff --git a/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenCoordinator.swift b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenCoordinator.swift new file mode 100644 index 000000000..2a6551653 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenCoordinator.swift @@ -0,0 +1,49 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +struct HealthChatInviteMemberScreenCoordinatorParameters { + let apiService: HealthChatAPIServiceProtocol + let userIndicatorController: UserIndicatorControllerProtocol +} + +enum HealthChatInviteMemberScreenCoordinatorAction { + case dismiss +} + +final class HealthChatInviteMemberScreenCoordinator: CoordinatorProtocol { + private let viewModel: HealthChatInviteMemberScreenViewModelProtocol + + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: HealthChatInviteMemberScreenCoordinatorParameters) { + viewModel = HealthChatInviteMemberScreenViewModel(apiService: parameters.apiService, + userIndicatorController: parameters.userIndicatorController) + } + + func start() { + viewModel.actionsPublisher.sink { [weak self] action in + guard let self else { return } + switch action { + case .dismiss: + actionsSubject.send(.dismiss) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(HealthChatInviteMemberScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenModels.swift b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenModels.swift new file mode 100644 index 000000000..318523daa --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenModels.swift @@ -0,0 +1,49 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum HealthChatInviteMemberScreenViewModelAction { + case dismiss +} + +struct HealthChatInviteMemberScreenViewState: BindableState { + /// The backend requires a personal note of at least this many characters. + static let minimumNoteLength = 10 + + var bindings = HealthChatInviteMemberScreenViewStateBindings() + + var canSend: Bool { + isEmailValid && isNoteValid + } + + var isEmailValid: Bool { + let email = bindings.email.trimmingCharacters(in: .whitespaces) + let components = email.split(separator: "@") + return components.count == 2 && components[1].contains(".") + } + + var isNoteValid: Bool { + bindings.personalNote.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumNoteLength + } +} + +struct HealthChatInviteMemberScreenViewStateBindings { + var email = "" + var personalNote = "" + + var alertInfo: AlertInfo? +} + +enum HealthChatInviteMemberScreenAlertType: Hashable { + case sendFailed +} + +enum HealthChatInviteMemberScreenViewAction { + case send + case cancel +} diff --git a/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModel.swift b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModel.swift new file mode 100644 index 000000000..b8d9859b1 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModel.swift @@ -0,0 +1,63 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +typealias HealthChatInviteMemberScreenViewModelType = StateStoreViewModelV2 + +class HealthChatInviteMemberScreenViewModel: HealthChatInviteMemberScreenViewModelType, HealthChatInviteMemberScreenViewModelProtocol { + private let apiService: HealthChatAPIServiceProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(apiService: HealthChatAPIServiceProtocol, + userIndicatorController: UserIndicatorControllerProtocol) { + self.apiService = apiService + self.userIndicatorController = userIndicatorController + + super.init(initialViewState: HealthChatInviteMemberScreenViewState(), mediaProvider: nil) + } + + // MARK: - Public + + override func process(viewAction: HealthChatInviteMemberScreenViewAction) { + switch viewAction { + case .send: + Task { await sendInvitation() } + case .cancel: + actionsSubject.send(.dismiss) + } + } + + // MARK: - Private + + private func sendInvitation() async { + let indicatorID = "\(Self.self)-Sending" + defer { userIndicatorController.retractIndicatorWithId(indicatorID) } + userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonLoading, + persistent: true), + delay: .milliseconds(100)) + + let email = state.bindings.email.trimmingCharacters(in: .whitespaces) + let personalNote = state.bindings.personalNote.trimmingCharacters(in: .whitespacesAndNewlines) + + switch await apiService.inviteMember(email: email, personalNote: personalNote) { + case .success: + userIndicatorController.submitIndicator(UserIndicator(title: HealthChatL10n.screenInviteMemberSuccess, icon: \.check)) + actionsSubject.send(.dismiss) + case .failure: + state.bindings.alertInfo = .init(id: .sendFailed) + } + } +} diff --git a/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModelProtocol.swift b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModelProtocol.swift new file mode 100644 index 000000000..dcf66c5e2 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/HealthChatInviteMemberScreenViewModelProtocol.swift @@ -0,0 +1,14 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine + +@MainActor +protocol HealthChatInviteMemberScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: HealthChatInviteMemberScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/HealthChatInviteMemberScreen/View/HealthChatInviteMemberScreen.swift b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/View/HealthChatInviteMemberScreen.swift new file mode 100644 index 000000000..36e191170 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatInviteMemberScreen/View/HealthChatInviteMemberScreen.swift @@ -0,0 +1,71 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct HealthChatInviteMemberScreen: View { + @Bindable var context: HealthChatInviteMemberScreenViewModel.Context + + var body: some View { + Form { + Section { + ListRow(label: .plain(title: HealthChatL10n.screenInviteMemberEmailPlaceholder), + kind: .textField(text: $context.email, axis: .horizontal)) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + ListRow(label: .plain(title: HealthChatL10n.screenInviteMemberNotePlaceholder), + kind: .textField(text: $context.personalNote, axis: .vertical)) + .lineLimit(4...) + } footer: { + Text(HealthChatL10n.screenInviteMemberNoteFooter) + .compoundListSectionFooter() + } + } + .compoundList() + .scrollDismissesKeyboard(.immediately) + .navigationTitle(HealthChatL10n.screenInviteMemberTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .alert(item: $context.alertInfo) + } + + // MARK: - Private + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.actionCancel) { + context.send(viewAction: .cancel) + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionSend) { + context.send(viewAction: .send) + } + .disabled(!context.viewState.canSend) + } + } +} + +// MARK: - Previews + +struct HealthChatInviteMemberScreen_Previews: PreviewProvider, TestablePreview { + static let viewModel = HealthChatInviteMemberScreenViewModel(apiService: HealthChatAPIServiceMock(), + userIndicatorController: UserIndicatorControllerMock()) + + static var previews: some View { + ElementNavigationStack { + HealthChatInviteMemberScreen(context: viewModel.context) + } + .previewDisplayName("Invite") + } +} diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift index 3a014d6d2..92038d4b2 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenCoordinator.swift @@ -18,6 +18,8 @@ struct HealthChatMemberDirectoryScreenCoordinatorParameters { enum HealthChatMemberDirectoryScreenCoordinatorAction { case selectMember(userID: String) case presentRoom(roomID: String) + case editProfile + case invite } final class HealthChatMemberDirectoryScreenCoordinator: CoordinatorProtocol { @@ -45,6 +47,10 @@ final class HealthChatMemberDirectoryScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.selectMember(userID: userID)) case .presentRoom(let roomID): actionsSubject.send(.presentRoom(roomID: roomID)) + case .editProfile: + actionsSubject.send(.editProfile) + case .invite: + actionsSubject.send(.invite) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift index 83459672d..a16dedcea 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenModels.swift @@ -10,6 +10,8 @@ import Foundation enum HealthChatMemberDirectoryScreenViewModelAction { case selectMember(userID: String) case presentRoom(roomID: String) + case editProfile + case invite } nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable { @@ -22,6 +24,12 @@ nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable { struct HealthChatMemberDirectoryScreenViewState: BindableState { var content: HealthChatMemberDirectoryScreenContent = .loading + /// Inviting others is only available to approved accounts. + var isApproved = false + + /// The hint shown while the own profile is missing required fields. + var completenessMessage: String? + var bindings = HealthChatMemberDirectoryScreenViewStateBindings() } @@ -38,4 +46,6 @@ enum HealthChatMemberDirectoryScreenViewAction { case retry case selectMember(HealthChatMember) case startChat(HealthChatMember) + case editProfile + case invite } diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift index 9727cf9f7..9207b4f8a 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/HealthChatMemberDirectoryScreenViewModel.swift @@ -64,6 +64,10 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV actionsSubject.send(.selectMember(userID: member.userID)) case .startChat(let member): Task { await startChat(with: member) } + case .editProfile: + actionsSubject.send(.editProfile) + case .invite: + actionsSubject.send(.invite) } } @@ -80,8 +84,13 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV switch result { case .success(let response) where !response.isApproved: + state.isApproved = false + state.completenessMessage = nil state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval) case .success(let response): + state.isApproved = true + await updateCompletenessMessage() + guard !Task.isCancelled else { return } state.content = .members(response.users) case .failure: state.content = .failure @@ -89,6 +98,15 @@ class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenV } } + private func updateCompletenessMessage() async { + // Best-effort hint — a failed check just hides it. + guard case let .success(completeness) = await apiService.profileCompleteness() else { + state.completenessMessage = nil + return + } + state.completenessMessage = completeness.isComplete ? nil : completeness.message + } + private func startChat(with member: HealthChatMember) async { switch clientProxy.directRoomForUserID(member.userID) { case .success(let roomID?): diff --git a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift index f85d79628..cc1372a48 100644 --- a/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift +++ b/ElementX/Sources/Screens/HealthChatMemberDirectoryScreen/View/HealthChatMemberDirectoryScreen.swift @@ -18,9 +18,33 @@ struct HealthChatMemberDirectoryScreen: View { .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) .searchController(query: $context.searchQuery, placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder) + .toolbar { toolbar } .alert(item: $context.alertInfo) } + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + Button { + context.send(viewAction: .editProfile) + } label: { + CompoundIcon(\.edit) + } + .accessibilityLabel(L10n.screenEditProfileTitle) + } + + if context.viewState.isApproved { + ToolbarItem(placement: .primaryAction) { + Button { + context.send(viewAction: .invite) + } label: { + CompoundIcon(\.userAdd) + } + .accessibilityLabel(HealthChatL10n.screenInviteMemberTitle) + } + } + } + // MARK: - Private @ViewBuilder @@ -42,6 +66,15 @@ struct HealthChatMemberDirectoryScreen: View { private func memberList(_ members: [HealthChatMember]) -> some View { Form { + if let completenessMessage = context.viewState.completenessMessage { + Section { + ListRow(label: .default(title: completenessMessage, icon: \.info), + kind: .button { + context.send(viewAction: .editProfile) + }) + } + } + Section { ForEach(members) { member in HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) { @@ -91,6 +124,7 @@ struct HealthChatMemberDirectoryScreen: View { struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePreview { static let loadedViewModel = makeViewModel(configuration: .members) + static let incompleteProfileViewModel = makeViewModel(configuration: .incompleteProfile) static let pendingViewModel = makeViewModel(configuration: .pendingApproval) static let failureViewModel = makeViewModel(configuration: .failure) @@ -101,6 +135,12 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie .snapshotPreferences(expect: loadedViewModel.context.observe(\.viewState.content).map { $0 != .loading }) .previewDisplayName("Members") + ElementNavigationStack { + HealthChatMemberDirectoryScreen(context: incompleteProfileViewModel.context) + } + .snapshotPreferences(expect: incompleteProfileViewModel.context.observe(\.viewState.completenessMessage).map { $0 != nil }) + .previewDisplayName("Incomplete profile") + ElementNavigationStack { HealthChatMemberDirectoryScreen(context: pendingViewModel.context) } @@ -116,6 +156,7 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie enum PreviewConfiguration { case members + case incompleteProfile case pendingApproval case failure } @@ -149,6 +190,17 @@ struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePrevie apiService.membersSearchQueryReturnValue = .success(.init(users: members ?? [], isApproved: true, pendingMessage: nil)) + apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self, + from: Data(#"{ "isComplete": true }"#.utf8))) + case .incompleteProfile: + let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data(""" + { "userId": "@alice:healthchat.ch", "displayName": "Dr. Alice Alpha", "specialty": "Physiotherapie" } + """.utf8)) + apiService.membersSearchQueryReturnValue = .success(.init(users: member.map { [$0] } ?? [], + isApproved: true, + pendingMessage: nil)) + apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self, + from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8))) case .pendingApproval: apiService.membersSearchQueryReturnValue = .success(.init(users: [], isApproved: false, diff --git a/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenCoordinator.swift b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenCoordinator.swift new file mode 100644 index 000000000..cc1e54264 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenCoordinator.swift @@ -0,0 +1,88 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +struct HealthChatProfileEditScreenCoordinatorParameters { + let orientationManager: OrientationManagerProtocol + let userSession: UserSessionProtocol + let apiService: HealthChatAPIServiceProtocol + let mediaUploadingPreprocessor: MediaUploadingPreprocessor + weak var navigationStackCoordinator: NavigationStackCoordinator? + let userIndicatorController: UserIndicatorControllerProtocol +} + +final class HealthChatProfileEditScreenCoordinator: CoordinatorProtocol, UserDetailsEditCoordinatorProtocol { + private let parameters: HealthChatProfileEditScreenCoordinatorParameters + private let viewModel: HealthChatProfileEditScreenViewModelProtocol + private var cancellables = Set() + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: HealthChatProfileEditScreenCoordinatorParameters) { + self.parameters = parameters + + viewModel = HealthChatProfileEditScreenViewModel(userSession: parameters.userSession, + apiService: parameters.apiService, + mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor, + userIndicatorController: parameters.userIndicatorController) + } + + func start() { + viewModel.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + actionsSubject.send(.dismiss) + case .displayCameraPicker: + displayMediaPickerWithMode(.init(source: .camera, selectionType: .single)) + case .displayMediaPicker: + displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single)) + case .displayFilePicker: + displayMediaPickerWithMode(.init(source: .documents(types: [.image]), selectionType: .single)) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(HealthChatProfileEditScreen(context: viewModel.context)) + } + + // MARK: Private + + private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) { + let stackCoordinator = NavigationStackCoordinator() + + let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode, + userIndicatorController: parameters.userIndicatorController, + orientationManager: parameters.orientationManager) { [weak self] action in + guard let self else { return } + switch action { + case .cancel: + parameters.navigationStackCoordinator?.setSheetCoordinator(nil) + case .selectedMediaAtURLs(let urls): + guard urls.count == 1, + let url = urls.first else { + fatalError("Received an invalid number of URLs") + } + + parameters.navigationStackCoordinator?.setSheetCoordinator(nil) + viewModel.didSelectMediaURL(url: url) + } + } + + stackCoordinator.setRootCoordinator(mediaPickerCoordinator) + parameters.navigationStackCoordinator?.setSheetCoordinator(stackCoordinator) + } +} diff --git a/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenModels.swift b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenModels.swift new file mode 100644 index 000000000..a84175c23 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenModels.swift @@ -0,0 +1,116 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum HealthChatProfileEditScreenViewModelAction { + case dismiss + case displayCameraPicker + case displayMediaPicker + case displayFilePicker +} + +struct HealthChatProfileEditScreenViewState: BindableState { + var initialProfile: HealthChatMember? + var loadFailed = false + + var canEditAvatar = false + var selectedAvatarURL: URL? + var localMedia: MediaInfo? + + var bindings = HealthChatProfileEditScreenViewStateBindings() + + var avatarDidChange: Bool { + localMedia != nil || selectedAvatarURL != initialProfile?.avatarURL + } + + /// The changed text fields as a partial update, nil when nothing changed. + var fieldsUpdate: HealthChatProfileUpdate? { + guard let initialProfile else { return nil } + + var update = HealthChatProfileUpdate() + // nil leaves a field unchanged server-side, an empty string clears it. + func diff(_ binding: String, _ initialValue: String?) -> String? { + let value = binding.trimmingCharacters(in: .whitespacesAndNewlines) + return value == (initialValue ?? "") ? nil : value + } + + update.title = diff(bindings.title, initialProfile.title) + update.firstName = diff(bindings.firstName, initialProfile.firstName) + update.lastName = diff(bindings.lastName, initialProfile.lastName) + update.specialty = diff(bindings.specialty, initialProfile.specialty) + update.practiceName = diff(bindings.practiceName, initialProfile.practiceName) + update.businessCity = diff(bindings.businessCity, initialProfile.businessCity) + update.businessEmail = diff(bindings.businessEmail, initialProfile.businessEmail) + update.businessTel = diff(bindings.businessTel, initialProfile.businessTel) + update.furtherInfo = diff(bindings.furtherInfo, initialProfile.furtherInfo) + update.street = diff(bindings.street, initialProfile.street) + update.houseNumber = diff(bindings.houseNumber, initialProfile.houseNumber) + update.postalCode = diff(bindings.postalCode, initialProfile.postalCode) + update.website = diff(bindings.website, initialProfile.website) + + return update == HealthChatProfileUpdate() ? nil : update + } + + /// First name, last name, speciality, practice and city are required (web client parity). + var requiredFieldsAreValid: Bool { + let requiredFields = [bindings.firstName, bindings.lastName, bindings.specialty, + bindings.practiceName, bindings.businessCity] + guard requiredFields.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + return false + } + + let website = bindings.website.trimmingCharacters(in: .whitespacesAndNewlines) + return website.isEmpty || URL(string: website) != nil + } + + var canSave: Bool { + initialProfile != nil && requiredFieldsAreValid && (avatarDidChange || fieldsUpdate != nil) + } + + var showDeleteImageAction: Bool { + localMedia != nil || selectedAvatarURL != nil + } +} + +struct HealthChatProfileEditScreenViewStateBindings { + var title = "" + var firstName = "" + var lastName = "" + var specialty = "" + var practiceName = "" + var businessCity = "" + var businessEmail = "" + var businessTel = "" + var furtherInfo = "" + var street = "" + var houseNumber = "" + var postalCode = "" + var website = "" + + var showMediaSheet = false + + var alertInfo: AlertInfo? +} + +enum HealthChatProfileEditScreenAlertType { + case failedProcessingMedia + case unsavedChanges + case saveError + case unknown +} + +enum HealthChatProfileEditScreenViewAction { + case cancel + case save + case retry + case presentMediaSource + case displayCameraPicker + case displayMediaPicker + case displayFilePicker + case removeImage +} diff --git a/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModel.swift b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModel.swift new file mode 100644 index 000000000..a668828dd --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModel.swift @@ -0,0 +1,158 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import SwiftUI + +typealias HealthChatProfileEditScreenViewModelType = StateStoreViewModelV2 + +class HealthChatProfileEditScreenViewModel: HealthChatProfileEditScreenViewModelType, HealthChatProfileEditScreenViewModelProtocol { + private let clientProxy: ClientProxyProtocol + private let apiService: HealthChatAPIServiceProtocol + private let mediaUploadingPreprocessor: MediaUploadingPreprocessor + private let userIndicatorController: UserIndicatorControllerProtocol + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(userSession: UserSessionProtocol, + apiService: HealthChatAPIServiceProtocol, + mediaUploadingPreprocessor: MediaUploadingPreprocessor, + userIndicatorController: UserIndicatorControllerProtocol) { + clientProxy = userSession.clientProxy + self.apiService = apiService + self.mediaUploadingPreprocessor = mediaUploadingPreprocessor + self.userIndicatorController = userIndicatorController + + super.init(initialViewState: HealthChatProfileEditScreenViewState(), mediaProvider: userSession.mediaProvider) + + Task { await loadProfile() } + } + + // MARK: - Public + + override func process(viewAction: HealthChatProfileEditScreenViewAction) { + switch viewAction { + case .cancel: + showUnsavedChangesAlert() // The cancel button is only shown when there are unsaved changes. + case .save: + Task { await saveProfile() } + case .retry: + Task { await loadProfile() } + case .presentMediaSource: + state.bindings.showMediaSheet = true + case .displayCameraPicker: + actionsSubject.send(.displayCameraPicker) + case .displayMediaPicker: + actionsSubject.send(.displayMediaPicker) + case .displayFilePicker: + actionsSubject.send(.displayFilePicker) + case .removeImage: + state.localMedia = nil + state.selectedAvatarURL = nil + } + } + + func didSelectMediaURL(url: URL) { + Task { + let userIndicatorID = UUID().uuidString + defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) } + userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.commonLoading, + persistent: true)) + + guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { + MXLog.error("Failed to get max upload size") + state.bindings.alertInfo = .init(id: .unknown) + return + } + let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) + + switch mediaResult { + case .success(.image): + state.localMedia = try? mediaResult.get() + case .failure, .success: + state.bindings.alertInfo = .init(id: .failedProcessingMedia) + } + } + } + + // MARK: - Private + + private func loadProfile() async { + state.loadFailed = false + + state.canEditAvatar = await clientProxy.capabilities.canChangeAvatar() + + switch await apiService.ownProfile() { + case .success(let profile): + state.selectedAvatarURL = profile.avatarURL + state.bindings.title = profile.title ?? "" + state.bindings.firstName = profile.firstName ?? "" + state.bindings.lastName = profile.lastName ?? "" + state.bindings.specialty = profile.specialty ?? "" + state.bindings.practiceName = profile.practiceName ?? "" + state.bindings.businessCity = profile.businessCity ?? "" + state.bindings.businessEmail = profile.businessEmail ?? "" + state.bindings.businessTel = profile.businessTel ?? "" + state.bindings.furtherInfo = profile.furtherInfo ?? "" + state.bindings.street = profile.street ?? "" + state.bindings.houseNumber = profile.houseNumber ?? "" + state.bindings.postalCode = profile.postalCode ?? "" + state.bindings.website = profile.website ?? "" + state.initialProfile = profile + case .failure: + state.loadFailed = true + } + } + + private func showUnsavedChangesAlert() { + state.bindings.alertInfo = .init(id: .unsavedChanges, + title: L10n.dialogUnsavedChangesTitle, + message: L10n.dialogUnsavedChangesDescription, + primaryButton: .init(title: L10n.actionSave) { Task { await self.saveProfile() } }, + secondaryButton: .init(title: L10n.actionDiscard, role: .cancel) { self.actionsSubject.send(.dismiss) }) + } + + private func saveProfile() async { + let userIndicatorID = UUID().uuidString + defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) } + userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, + type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), + title: L10n.screenEditProfileUpdatingDetails, + persistent: true)) + + do { + if state.avatarDidChange { + try await saveAvatar() + } + + // The fields go second so the derived display name reflects the latest names. + if let update = state.fieldsUpdate { + try await apiService.updateOwnProfile(update).get() + } + + actionsSubject.send(.dismiss) + } catch { + MXLog.error("Failed saving the HealthChat profile with error: \(error)") + state.bindings.alertInfo = .init(id: .saveError, + title: L10n.screenEditProfileErrorTitle, + message: L10n.screenEditProfileError) + } + } + + private func saveAvatar() async throws { + if let localMedia = state.localMedia { + try await clientProxy.setUserAvatar(media: localMedia).get() + } else if state.selectedAvatarURL == nil { + try await clientProxy.removeUserAvatar().get() + } + } +} diff --git a/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModelProtocol.swift b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModelProtocol.swift new file mode 100644 index 000000000..a2672b77d --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatProfileEditScreen/HealthChatProfileEditScreenViewModelProtocol.swift @@ -0,0 +1,17 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import Foundation + +@MainActor +protocol HealthChatProfileEditScreenViewModelProtocol { + var actionsPublisher: AnyPublisher { get } + var context: HealthChatProfileEditScreenViewModelType.Context { get } + + func didSelectMediaURL(url: URL) +} diff --git a/ElementX/Sources/Screens/HealthChatProfileEditScreen/View/HealthChatProfileEditScreen.swift b/ElementX/Sources/Screens/HealthChatProfileEditScreen/View/HealthChatProfileEditScreen.swift new file mode 100644 index 000000000..92a953073 --- /dev/null +++ b/ElementX/Sources/Screens/HealthChatProfileEditScreen/View/HealthChatProfileEditScreen.swift @@ -0,0 +1,220 @@ +// +// Copyright 2026 Novaloop AG +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct HealthChatProfileEditScreen: View { + @Bindable var context: HealthChatProfileEditScreenViewModel.Context + + var body: some View { + content + .navigationTitle(L10n.screenEditProfileTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(context.viewState.canSave) + .toolbar { toolbar } + .alert(item: $context.alertInfo) + } + + // MARK: - Private + + @ViewBuilder + private var content: some View { + if context.viewState.loadFailed { + failureView + } else { + form + } + } + + private var form: some View { + Form { + Section { + avatar + } + + Section { + textFieldRow(HealthChatL10n.commonAcademicTitle, text: $context.title) + textFieldRow(requiredLabel(HealthChatL10n.commonFirstName), text: $context.firstName) + textFieldRow(requiredLabel(HealthChatL10n.commonLastName), text: $context.lastName) + } header: { + Text(HealthChatL10n.screenProfileEditSectionPersonal) + .compoundListSectionHeader() + } + + Section { + textFieldRow(requiredLabel(HealthChatL10n.commonSpecialty), text: $context.specialty) + textFieldRow(requiredLabel(HealthChatL10n.commonPracticeName), text: $context.practiceName) + textFieldRow(requiredLabel(HealthChatL10n.commonCity), text: $context.businessCity) + textFieldRow(HealthChatL10n.commonFurtherInfo, text: $context.furtherInfo, axis: .vertical) + } header: { + Text(HealthChatL10n.screenProfileEditSectionProfessional) + .compoundListSectionHeader() + } + + Section { + textFieldRow(HealthChatL10n.commonBusinessEmail, text: $context.businessEmail) + textFieldRow(HealthChatL10n.commonBusinessTel, text: $context.businessTel) + } header: { + Text(HealthChatL10n.screenProfileEditSectionContact) + .compoundListSectionHeader() + } + + Section { + textFieldRow(HealthChatL10n.commonStreet, text: $context.street) + textFieldRow(HealthChatL10n.commonHouseNumber, text: $context.houseNumber) + textFieldRow(HealthChatL10n.commonPostalCode, text: $context.postalCode) + textFieldRow(HealthChatL10n.commonWebsite, text: $context.website) + } header: { + Text(HealthChatL10n.screenProfileEditSectionAddress) + .compoundListSectionHeader() + } + } + .compoundList() + .scrollDismissesKeyboard(.immediately) + .disabled(context.viewState.initialProfile == nil) + } + + private var failureView: some View { + VStack(spacing: 16) { + BigIcon(icon: \.error) + + Text(L10n.commonSomethingWentWrong) + .font(.compound.bodyLG) + .foregroundColor(.compound.textSecondary) + .multilineTextAlignment(.center) + + Button(L10n.actionRetry) { + context.send(viewAction: .retry) + } + .buttonStyle(.compound(.secondary, size: .medium)) + .fixedSize() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + if context.viewState.canSave { + Button(L10n.actionCancel) { + context.send(viewAction: .cancel) + } + } + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionSave) { + context.send(viewAction: .save) + } + .disabled(!context.viewState.canSave) + } + } + + private var avatar: some View { + Button { + context.send(viewAction: .presentMediaSource) + } label: { + OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL, + url: context.viewState.selectedAvatarURL, + name: context.viewState.initialProfile?.displayName, + contentID: context.viewState.initialProfile?.userID ?? "", + shape: .circle, + avatarSize: .user(on: .editUserDetails), + mediaProvider: context.mediaProvider) + .confirmationDialog("", isPresented: $context.showMediaSheet) { + mediaActionSheet + } + } + .buttonStyle(EditAvatarButtonStyle()) + .disabled(!context.viewState.canEditAvatar) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + } + + private func textFieldRow(_ label: String, text: Binding, axis: Axis = .horizontal) -> some View { + ListRow(label: .plain(title: label), + kind: .textField(text: text, axis: axis)) + } + + private func requiredLabel(_ label: String) -> String { + "\(label) *" + } + + @ViewBuilder + private var mediaActionSheet: some View { + Button { + context.send(viewAction: .displayCameraPicker) + } label: { + Text(L10n.actionTakePhoto) + } + + Button { + context.send(viewAction: .displayMediaPicker) + } label: { + Text(L10n.actionChoosePhoto) + } + + Button { + context.send(viewAction: .displayFilePicker) + } label: { + Text(L10n.actionChooseFile) + } + + if context.viewState.showDeleteImageAction { + Button(role: .destructive) { + context.send(viewAction: .removeImage) + } label: { + Text(L10n.actionRemove) + } + } + } +} + +// MARK: - Previews + +struct HealthChatProfileEditScreen_Previews: PreviewProvider, TestablePreview { + static let viewModel = makeViewModel() + + static var previews: some View { + ElementNavigationStack { + HealthChatProfileEditScreen(context: viewModel.context) + } + .snapshotPreferences(expect: viewModel.context.observe(\.viewState.initialProfile).map { $0 != nil }) + .previewDisplayName("Profile") + } + + static func makeViewModel() -> HealthChatProfileEditScreenViewModel { + let apiService = HealthChatAPIServiceMock() + let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data(""" + { + "userId": "@alice:healthchat.ch", + "displayName": "Dr. Alice Alpha", + "title": "Dr. med.", + "firstName": "Alice", + "lastName": "Alpha", + "specialty": "Physiotherapie", + "practiceName": "Praxis Alpha", + "businessCity": "Zürich", + "businessEmail": "praxis@example.ch", + "businessTel": "+41 44 123 45 67", + "street": "Friedaustrasse", + "houseNumber": "17", + "postalCode": "8003", + "website": "https://example.ch" + } + """.utf8)) + if let member { + apiService.ownProfileReturnValue = .success(member) + } + + return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))), + apiService: apiService, + mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index f866c24f8..b92723c40 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -347,6 +347,13 @@ extension PreviewTests { } } + @Test + func healthChatInviteMemberScreen() async throws { + for (index, preview) in HealthChatInviteMemberScreen_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func healthChatMemberDirectoryScreen() async throws { for (index, preview) in HealthChatMemberDirectoryScreen_Previews._allPreviews.enumerated() { @@ -354,6 +361,13 @@ extension PreviewTests { } } + @Test + func healthChatProfileEditScreen() async throws { + for (index, preview) in HealthChatProfileEditScreen_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func highlightedTimelineItemModifier() async throws { for (index, preview) in HighlightedTimelineItemModifier_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-en-GB.png new file mode 100644 index 000000000..09f917e77 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f38be86565d33d4b05fc8900bd1eab780c2274c8e1ad412e9a7f1d6a564545c +size 102677 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-pseudo.png new file mode 100644 index 000000000..a9c416949 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea5b29ae3a83d73ff3c518bcff5fd7beb4f4ec0649e03c9baad29c25ac66ab81 +size 109760 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-en-GB.png new file mode 100644 index 000000000..5d1b17dc9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2e0cc83bbd0965fa3dac8c95474d4a49e07090d79e188ac8735fcdd2a95b805 +size 52217 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-pseudo.png new file mode 100644 index 000000000..5d4f94d53 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatInviteMemberScreen.Invite-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db0c766615367a567ae970a4c0dd10f17b26a2e05a2dbc85a5f855e6fc2e609c +size 58946 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-en-GB.png new file mode 100644 index 000000000..cf74821be --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13130e09d9e5ae841fc6e269dcf9420cffe64f69583d3580a56b7334d353e7fc +size 109359 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-pseudo.png new file mode 100644 index 000000000..dc1be1d67 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cba1ef512bad9756ada9561ce7a8a94040d32249c3b3caa136eb14c42c9af42 +size 111733 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-en-GB.png new file mode 100644 index 000000000..edeef04d1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0deb076847dba35be2cac3b4985ca562640b533b4a3fdfff077c2e3f87b8d964 +size 63549 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-pseudo.png new file mode 100644 index 000000000..f6a208102 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Incomplete-profile-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f1e02b607867f252f52c17ee681c379f9f19f4ea149591f0a972efa597cff7a +size 64395 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png index 759fc0854..60d8b4586 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:835f7263895535cfec1d671cedeb84751be94545c1ca3670e55ee8d1e09b2bf7 -size 115087 +oid sha256:5dca1592ef70e7460d8445380ee694b0b470a9e19606252201c65041b4026046 +size 116242 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png index 3bb95e882..8bf4870e7 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d45d4bce9720d2953fc778eb008fd5a126a45cf01ecf9eeb98ad2ef203733ed -size 117358 +oid sha256:08da7f3910560b00052315c152be0c9c3aa7db8bd54c5e94e60f1f27c95e4ca9 +size 118373 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png index a6c74aa90..c81eba71f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f5f4c69f3edb9af10ce7286084d969b67d949c5d3499d3ef49eb1b82caa19a7 -size 68274 +oid sha256:92ea3078cb7500856dc31a0d83c051eb619e045165c26b0052e09d8ca693514f +size 69212 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png index ea8b41287..189132a15 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatMemberDirectoryScreen.Members-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a52d8441c8052e6b633dbf0aba62d55912ab177008764b06d65c99420a002085 -size 69205 +oid sha256:9cc8f7ff144aa1e72d67a4cc90dcb4888f6e71bf77f8daf1ad27f7f3c13340bf +size 70058 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-en-GB.png new file mode 100644 index 000000000..ac9f49f19 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbe25e139b3b4f8c380361365fa8f32ac66b5e17d1de68640900dabd6e7daf96 +size 138163 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-pseudo.png new file mode 100644 index 000000000..6435fa495 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4aaf0ae37a6e29c9ec4c2e8f93b24c570d7fab5b99060ee3ee3e22918450042 +size 146640 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-en-GB.png new file mode 100644 index 000000000..6c47efa13 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a796b5a0b707ea279b70a6c119106866df83c1eb57b3953fd572d02bdf53d7d +size 87763 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-pseudo.png new file mode 100644 index 000000000..0db55e238 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/healthChatProfileEditScreen.Profile-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:100fa2d919cd78d957c7c8dac52e7013fec340ee01ccd18f0351229a7cf78e75 +size 94975 diff --git a/UnitTests/Sources/HealthChat/HealthChatInviteMemberScreenViewModelTests.swift b/UnitTests/Sources/HealthChat/HealthChatInviteMemberScreenViewModelTests.swift new file mode 100644 index 000000000..59419c405 --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatInviteMemberScreenViewModelTests.swift @@ -0,0 +1,76 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatInviteMemberScreenViewModelTests { + private let apiService = HealthChatAPIServiceMock() + private let viewModel: HealthChatInviteMemberScreenViewModel + + init() { + viewModel = HealthChatInviteMemberScreenViewModel(apiService: apiService, + userIndicatorController: UserIndicatorControllerMock()) + } + + @Test + func sendingRequiresValidEmailAndNote() { + #expect(!viewModel.context.viewState.canSend) + + viewModel.context.email = "new@example.ch" + viewModel.context.personalNote = "Zu kurz" + #expect(!viewModel.context.viewState.canSend) + + viewModel.context.personalNote = "Bitte tritt unserem Netzwerk bei!" + #expect(viewModel.context.viewState.canSend) + + viewModel.context.email = "not-an-email" + #expect(!viewModel.context.viewState.canSend) + } + + @Test + func sendSuccessDismisses() async throws { + apiService.inviteMemberEmailPersonalNoteReturnValue = .success(()) + + viewModel.context.email = "new@example.ch" + viewModel.context.personalNote = "Bitte tritt unserem Netzwerk bei!" + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .dismiss = action else { return false } + return true + } + viewModel.context.send(viewAction: .send) + try await deferred.fulfill() + + let arguments = try #require(apiService.inviteMemberEmailPersonalNoteReceivedArguments) + #expect(arguments.email == "new@example.ch") + #expect(arguments.personalNote == "Bitte tritt unserem Netzwerk bei!") + } + + @Test + func sendFailureShowsAlert() async throws { + apiService.inviteMemberEmailPersonalNoteReturnValue = .failure(.networkFailure) + + viewModel.context.email = "new@example.ch" + viewModel.context.personalNote = "Bitte tritt unserem Netzwerk bei!" + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.bindings.alertInfo)) { $0 != nil } + viewModel.context.send(viewAction: .send) + try await deferred.fulfill() + } + + @Test + func cancelDismisses() async throws { + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .dismiss = action else { return false } + return true + } + viewModel.context.send(viewAction: .cancel) + try await deferred.fulfill() + } +} diff --git a/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift index 7d78ccb0c..65f840250 100644 --- a/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift +++ b/UnitTests/Sources/HealthChat/HealthChatMemberDirectoryScreenViewModelTests.swift @@ -13,6 +13,10 @@ final class HealthChatMemberDirectoryScreenViewModelTests { private let apiService = HealthChatAPIServiceMock() private let clientProxy = ClientProxyMock(.init()) + init() throws { + apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: true, message: nil)) + } + @Test func loadsMembersOnStart() async throws { apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) @@ -126,8 +130,89 @@ final class HealthChatMemberDirectoryScreenViewModelTests { try await deferred.fulfill() } + @Test + func showsCompletenessHintWhenProfileIsIncomplete() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: false, + message: "Profil unvollständig")) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.completenessMessage)) { $0 == "Profil unvollständig" } + try await deferred.fulfill() + + #expect(viewModel.context.viewState.isApproved) + } + + @Test + func hidesCompletenessHintWhenProfileIsComplete() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: ["@alice:healthchat.ch"])) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .members = content else { return false } + return true + } + try await deferred.fulfill() + + #expect(viewModel.context.viewState.completenessMessage == nil) + } + + @Test + func pendingApprovalDisablesInviting() async throws { + apiService.membersSearchQueryReturnValue = .success(.init(users: [], + isApproved: false, + pendingMessage: "Ihr Konto wird geprüft")) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.content)) { content in + guard case .pendingApproval = content else { return false } + return true + } + try await deferred.fulfill() + + #expect(!viewModel.context.viewState.isApproved) + } + + @Test + func editProfileEmitsAction() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [])) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .editProfile = action else { return false } + return true + } + viewModel.context.send(viewAction: .editProfile) + try await deferred.fulfill() + } + + @Test + func inviteEmitsAction() async throws { + apiService.membersSearchQueryReturnValue = try .success(makeResponse(userIDs: [])) + + let viewModel = makeViewModel() + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .invite = action else { return false } + return true + } + viewModel.context.send(viewAction: .invite) + try await deferred.fulfill() + } + // MARK: - Helpers + private func makeCompleteness(isComplete: Bool, message: String?) throws -> HealthChatProfileCompleteness { + let messageJSON = message.map { "\"\($0)\"" } ?? "null" + return try JSONDecoder().decode(HealthChatProfileCompleteness.self, from: Data(""" + { "isComplete": \(isComplete), "message": \(messageJSON) } + """.utf8)) + } + private func makeViewModel() -> HealthChatMemberDirectoryScreenViewModel { HealthChatMemberDirectoryScreenViewModel(apiService: apiService, clientProxy: clientProxy, diff --git a/UnitTests/Sources/HealthChat/HealthChatProfileEditScreenViewModelTests.swift b/UnitTests/Sources/HealthChat/HealthChatProfileEditScreenViewModelTests.swift new file mode 100644 index 000000000..6783489db --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatProfileEditScreenViewModelTests.swift @@ -0,0 +1,140 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatProfileEditScreenViewModelTests { + private let apiService = HealthChatAPIServiceMock() + private let clientProxy = ClientProxyMock(.init()) + + @Test + func loadsProfileIntoBindings() async throws { + apiService.ownProfileReturnValue = try .success(makeProfile()) + + let viewModel = makeViewModel() + try await waitForLoad(of: viewModel) + + #expect(viewModel.context.firstName == "Alice") + #expect(viewModel.context.businessTel == "+41 44 123 45 67") + #expect(viewModel.context.furtherInfo == "Old info") + #expect(viewModel.context.viewState.canEditAvatar) + #expect(!viewModel.context.viewState.canSave) + } + + @Test + func saveSendsOnlyChangedFields() async throws { + apiService.ownProfileReturnValue = try .success(makeProfile()) + apiService.updateOwnProfileReturnValue = .success(()) + + let viewModel = makeViewModel() + try await waitForLoad(of: viewModel) + + viewModel.context.businessTel = "+41 79 111 22 33" + viewModel.context.furtherInfo = "" + + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .dismiss = action else { return false } + return true + } + viewModel.context.send(viewAction: .save) + try await deferred.fulfill() + + let update = try #require(apiService.updateOwnProfileReceivedUpdate) + #expect(update.businessTel == "+41 79 111 22 33") + #expect(update.furtherInfo == "") + #expect(update.firstName == nil) + #expect(update.title == nil) + } + + @Test + func missingRequiredFieldBlocksSaving() async throws { + apiService.ownProfileReturnValue = try .success(makeProfile()) + + let viewModel = makeViewModel() + try await waitForLoad(of: viewModel) + + viewModel.context.firstName = " " + + #expect(!viewModel.context.viewState.canSave) + } + + @Test + func saveFailureShowsAlert() async throws { + apiService.ownProfileReturnValue = try .success(makeProfile()) + apiService.updateOwnProfileReturnValue = .failure(.networkFailure) + + let viewModel = makeViewModel() + try await waitForLoad(of: viewModel) + + viewModel.context.businessTel = "+41 79 111 22 33" + + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.bindings.alertInfo)) { $0 != nil } + viewModel.context.send(viewAction: .save) + try await deferred.fulfill() + } + + @Test + func cancelWithChangesShowsUnsavedChangesAlert() async throws { + apiService.ownProfileReturnValue = try .success(makeProfile()) + + let viewModel = makeViewModel() + try await waitForLoad(of: viewModel) + + viewModel.context.businessTel = "+41 79 111 22 33" + viewModel.context.send(viewAction: .cancel) + + #expect(viewModel.context.viewState.bindings.alertInfo != nil) + } + + @Test + func loadFailureOffersRetry() async throws { + apiService.ownProfileReturnValue = .failure(.networkFailure) + + let viewModel = makeViewModel() + + let deferredFailure = deferFulfillment(viewModel.context.observe(\.viewState.loadFailed)) { $0 } + try await deferredFailure.fulfill() + + apiService.ownProfileReturnValue = try .success(makeProfile()) + + let deferredLoad = deferFulfillment(viewModel.context.observe(\.viewState.initialProfile)) { $0 != nil } + viewModel.context.send(viewAction: .retry) + try await deferredLoad.fulfill() + } + + // MARK: - Helpers + + private func makeViewModel() -> HealthChatProfileEditScreenViewModel { + HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + apiService: apiService, + mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), + userIndicatorController: UserIndicatorControllerMock()) + } + + private func waitForLoad(of viewModel: HealthChatProfileEditScreenViewModel) async throws { + let deferred = deferFulfillment(viewModel.context.observe(\.viewState.initialProfile)) { $0 != nil } + try await deferred.fulfill() + } + + private func makeProfile() throws -> HealthChatMember { + try JSONDecoder().decode(HealthChatMember.self, from: Data(""" + { + "userId": "@alice:healthchat.ch", + "displayName": "Dr. Alice Alpha", + "firstName": "Alice", + "lastName": "Alpha", + "specialty": "Physiotherapie", + "practiceName": "Praxis Alpha", + "businessCity": "Zürich", + "businessTel": "+41 44 123 45 67", + "furtherInfo": "Old info" + } + """.utf8)) + } +}