diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 87c6fd3ce..d5b233885 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -191,6 +191,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "GalleryRoomTimelineView_Previews") } + func testHealthChatAccountStatusSection() async throws { + try await performAccessibilityAudit(named: "HealthChatAccountStatusSection_Previews") + } + func testHealthChatExtendedProfileSection() async throws { try await performAccessibilityAudit(named: "HealthChatExtendedProfileSection_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a52b5f099..de80fa1f0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 03BD83E8BDD23AE059802E0D /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 03CDCA6243F89B194E3FAD17 /* EncryptionAuthenticity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955336CBD5ED73C792D1F580 /* EncryptionAuthenticity.swift */; }; 03F192C5C011AFA6068C9EAA /* SpaceServiceSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B1E4D1F0A6FAAE64B54D76 /* SpaceServiceSDKMock.swift */; }; + 04154A8A1D1A7A819D7CE8E4 /* HealthChatAccountStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3BFFFEF695209F27535BED /* HealthChatAccountStatus.swift */; }; 0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */; }; 044DD8F80231BC30570F7965 /* UserDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AAD845E53B0C8B5E0812C2 /* UserDiscoveryService.swift */; }; 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */; }; @@ -60,6 +61,7 @@ 0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */; }; 066A1E9B94723EE9F3038044 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47EBB5D698CE9A25BB553A2D /* Strings.swift */; }; 06B55882911B4BF5B14E9851 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227AC5D71A4CE43512062243 /* URL.swift */; }; + 06BF5F111812981DC65CC34F /* HealthChatAccountStatusLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DE3887AAA265406107ABE6 /* HealthChatAccountStatusLoader.swift */; }; 06D17F7813AA931FF18FD5D0 /* SDKListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5CD2993048222B64C45006 /* SDKListener.swift */; }; 06D3942496E9E0E655F14D21 /* NotificationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */; }; 06F8EDF52E33A2D36BCC1161 /* AppLockScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D6F88FE35A0979D2821E06 /* AppLockScreen.swift */; }; @@ -107,6 +109,7 @@ 0E8C480700870BB34A2A360F /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4003BC24B24C9E63D3304177 /* DeviceKit */; }; 0EA6537A07E2DC882AEA5962 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 187853A7E643995EE49FAD43 /* Localizable.stringsdict */; }; 0EBF0A411E1C46D4BFA2B087 /* ManageAuthorizedSpacesScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9CD3A0A39BC063438E12D4 /* ManageAuthorizedSpacesScreenCoordinator.swift */; }; + 0EDE5E11785CA18FC8BFF1CC /* HealthChatAccountStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 056FCFA596B562977CA89D58 /* HealthChatAccountStatusSection.swift */; }; 0EEC614342F823E5BF966C2C /* AppLockTimerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */; }; 0F6C8033FA60CFD36F7CA205 /* AppLockSetupPINScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */; }; 0F81DEE3E02A6549B20DF09A /* DeactivateAccountScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633924B26ACCD29C18BEF4E8 /* DeactivateAccountScreenViewModelProtocol.swift */; }; @@ -322,6 +325,7 @@ 3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; }; 31CCF3159E5CE1C05AE5781B /* HTMLFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */; }; 31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; }; + 323D76E640CDB21BD33AD9B9 /* HealthChatAccountStatusLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E851BFC1E8A0E1925D26CD14 /* HealthChatAccountStatusLoaderTests.swift */; }; 329D65DD7CC0E42CAD282EF7 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C6AE2DB4FE7FAEA6402663 /* Secrets.swift */; }; 32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; }; 32BFB9B4D2EC46196537B72F /* ContentScannerProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */; }; @@ -1072,6 +1076,7 @@ A2357AA4A188BC37085BC6F0 /* EditRoomAddressScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5FDFAA04174CC99FB66391C /* EditRoomAddressScreenViewModel.swift */; }; A23B8B27A1436A1049EEF68E /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; A2434D4DFB49A68E5CD0F53C /* MediaLoaderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */; }; + A2937A97959A0AFB064BE1DE /* SettingsScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56630FCC9F78E5E8F1A75770 /* SettingsScreenHook.swift */; }; A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75FE3F524B575D53787868C /* TimelineMediaPreviewRedactConfirmationView.swift */; }; A33784831AD880A670CAA9F9 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; A36AD251013402EDBD666C75 /* AppMediatorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC027034248429A438886B /* AppMediatorMock.swift */; }; @@ -1508,6 +1513,7 @@ EA2FECCD9E00D9784AC6017D /* PhishingDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB07F03461023BC39C730922 /* PhishingDetector.swift */; }; EA4848108A6FE53A4085C9B1 /* VolatileUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */; }; EA6613B29BA671F39CE1B1D2 /* ConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B383DCD3DCB19E00FD478A5F /* ConfirmationDialog.swift */; }; + EA66A9B88287B2E56EB4A95F /* HealthChatSettingsScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDAE8AB9A3CBD957EDD666ED /* HealthChatSettingsScreenHook.swift */; }; EA78A7512AFB1E5451744EB1 /* AppRouteURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E461B3C8BBBFCA400B268D14 /* AppRouteURLParserTests.swift */; }; EA8D941771E762A5D3D7FA0D /* FileMediaEventsTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */; }; EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECF11669EF253E98AA2977A /* CompletionSuggestionServiceProtocol.swift */; }; @@ -1822,6 +1828,7 @@ 0545AC444BEEA89FF8C509FD /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/InfoPlist.strings"; sourceTree = ""; }; 05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = ""; }; 05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = ""; }; + 056FCFA596B562977CA89D58 /* HealthChatAccountStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusSection.swift; sourceTree = ""; }; 057B747CF045D3C6C30EAB2C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fi; path = fi.lproj/Localizable.stringsdict; sourceTree = ""; }; 05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = ""; }; @@ -2232,6 +2239,7 @@ 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsAppCoordinator.swift; sourceTree = ""; }; 46D0BA44B1838E65B507B277 /* NotificationPermissionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreen.swift; sourceTree = ""; }; 46D560DDA3B20C82766ACFAD /* NotificationSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenViewModel.swift; sourceTree = ""; }; + 46DE3887AAA265406107ABE6 /* HealthChatAccountStatusLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusLoader.swift; sourceTree = ""; }; 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = ""; }; 471BB7276C97AF60B3A5463B /* RoomDirectorySearchProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchProxy.swift; sourceTree = ""; }; 475D47D0BFE961B02BAC5D49 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = id; path = id.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -2321,6 +2329,7 @@ 55BA4D3DFEA16CDD3FE99064 /* InverseMask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InverseMask.swift; sourceTree = ""; }; 5644919DB2022397D9D5825A /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = ""; }; 565F1B2B300597C616B37888 /* FullscreenDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenDialog.swift; sourceTree = ""; }; + 56630FCC9F78E5E8F1A75770 /* SettingsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenHook.swift; sourceTree = ""; }; 56852036214ABA9D7D305768 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift; sourceTree = ""; }; 56D6F88FE35A0979D2821E06 /* AppLockScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreen.swift; sourceTree = ""; }; 57084488B03BDB33C7B7CA0E /* ResolveVerifiedUserSendFailureScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenViewModelTests.swift; sourceTree = ""; }; @@ -3127,6 +3136,7 @@ D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreen.swift; sourceTree = ""; }; DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = ""; }; DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSession.swift; sourceTree = ""; }; + DA3BFFFEF695209F27535BED /* HealthChatAccountStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatus.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 = ""; }; @@ -3211,6 +3221,7 @@ E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSelectionToolbar.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTests.swift; sourceTree = ""; }; + E851BFC1E8A0E1925D26CD14 /* HealthChatAccountStatusLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusLoaderTests.swift; sourceTree = ""; }; E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = ""; }; E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = ""; }; @@ -3250,6 +3261,7 @@ ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = ""; }; ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = ""; }; + EDAE8AB9A3CBD957EDD666ED /* HealthChatSettingsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatSettingsScreenHook.swift; sourceTree = ""; }; EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceFlowCoordinator.swift; sourceTree = ""; }; EDF6E8208542BE7A02A94939 /* PresentationColorSchemeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationColorSchemeModifier.swift; sourceTree = ""; }; EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -4076,6 +4088,7 @@ 3674212CAB6C5143DD437AC4 /* RoomDetailsScreenHook.swift */, 99EB08B2C8B01F3BFE4B0CEC /* RoomMemberDetailsScreenHook.swift */, B343C5255FB408DDE853CFDF /* RoomScreenHook.swift */, + 56630FCC9F78E5E8F1A75770 /* SettingsScreenHook.swift */, 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */, DA5CC7FB1CF8C69041B62DD7 /* UserDetailsEditScreenHook.swift */, 8B35734F3743563566413C9A /* UserProfileScreenHook.swift */, @@ -5705,6 +5718,8 @@ 801BA878EE879E01E82F3A5F /* HealthChat */ = { isa = PBXGroup; children = ( + 46DE3887AAA265406107ABE6 /* HealthChatAccountStatusLoader.swift */, + 056FCFA596B562977CA89D58 /* HealthChatAccountStatusSection.swift */, E0B439012B010EC4C3DD24D0 /* HealthChatAppHooks.swift */, E1ECC44D40D05D3A6F0DE0F7 /* HealthChatAppSettingsHook.swift */, 7A60C2712F5607406E8B1C80 /* HealthChatBrandColors.swift */, @@ -5714,6 +5729,7 @@ AD3C983BFE38789B9C5EA7A5 /* HealthChatExtendedProfileSectionHook.swift */, 9EDD488380158986A75D6640 /* HealthChatHomeTabsHook.swift */, 715B60D68D20808E328DA1DA /* HealthChatSettings.swift */, + EDAE8AB9A3CBD957EDD666ED /* HealthChatSettingsScreenHook.swift */, CA14BE0D369AD8098C93F264 /* HealthChatUserDetailsEditScreenHook.swift */, ); path = HealthChat; @@ -5864,6 +5880,7 @@ children = ( 79AFBE494F9CCAAE8046203B /* ExtendedProfileServiceTests.swift */, 8AB4E616E6405AA5DD5A04EA /* ExtendedProfileTests.swift */, + E851BFC1E8A0E1925D26CD14 /* HealthChatAccountStatusLoaderTests.swift */, CB71F86BB7A3AC9B32845B5B /* HealthChatAPIModelsTests.swift */, 087194751572DF7CA65D1CCB /* HealthChatAPIServiceTests.swift */, BB7ABDCA2059497899F226DF /* HealthChatExtendedProfileLoaderTests.swift */, @@ -6693,6 +6710,7 @@ 5EF73F8BBCB34CC51C31995D /* ExtendedProfile.swift */, E5A1F965895E27CAEA93B58A /* ExtendedProfileService.swift */, 180F8DC392F9D8DA27AAF3E5 /* ExtendedProfileServiceProtocol.swift */, + DA3BFFFEF695209F27535BED /* HealthChatAccountStatus.swift */, CE02F1DAF9054BD6F960D893 /* HealthChatAPIModels.swift */, 6004777518436121012B76CF /* HealthChatAPIService.swift */, 994DECEF3C67C4A715B09C82 /* HealthChatAPIServiceProtocol.swift */, @@ -8527,6 +8545,7 @@ 07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */, C40957497CAFADB846784A2A /* HealthChatAPIModelsTests.swift in Sources */, F995036BBA39691CF7AD1F08 /* HealthChatAPIServiceTests.swift in Sources */, + 323D76E640CDB21BD33AD9B9 /* HealthChatAccountStatusLoaderTests.swift in Sources */, 99DE2B618703A58BC3303B92 /* HealthChatExtendedProfileLoaderTests.swift in Sources */, 72031674925CD5CC448FA060 /* HealthChatInviteMemberScreenViewModelTests.swift in Sources */, 60317E05BFDD46BD99C2871B /* HealthChatMemberDirectoryScreenViewModelTests.swift in Sources */, @@ -9072,6 +9091,9 @@ 8DB9BEBB357A9F27208A36EF /* HealthChatAPIModels.swift in Sources */, A87651F9995DE56EF727E24A /* HealthChatAPIService.swift in Sources */, 8D57FE274A1929697ACD8254 /* HealthChatAPIServiceProtocol.swift in Sources */, + 04154A8A1D1A7A819D7CE8E4 /* HealthChatAccountStatus.swift in Sources */, + 06BF5F111812981DC65CC34F /* HealthChatAccountStatusLoader.swift in Sources */, + 0EDE5E11785CA18FC8BFF1CC /* HealthChatAccountStatusSection.swift in Sources */, E7F27870397639C204402434 /* HealthChatAppHooks.swift in Sources */, 665314B4CB31904F107D2D3B /* HealthChatAppSettingsHook.swift in Sources */, 6DDA932A28F531791BDBFB08 /* HealthChatBrandColors.swift in Sources */, @@ -9099,6 +9121,7 @@ 1610E4C02793FFC187434A68 /* HealthChatProfileEditScreenViewModel.swift in Sources */, 48CD74299B25EF6034EFF68E /* HealthChatProfileEditScreenViewModelProtocol.swift in Sources */, 1E822B072359DD37EEA8CE78 /* HealthChatSettings.swift in Sources */, + EA66A9B88287B2E56EB4A95F /* HealthChatSettingsScreenHook.swift in Sources */, D1A0D9EBE3B9BF4E76DE309A /* HealthChatUserDetailsEditScreenHook.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */, @@ -9654,6 +9677,7 @@ 755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */, 34F1261CEF6D6A00D559B520 /* SettingsScreen.swift in Sources */, AF8BFA37791E1756EE243E08 /* SettingsScreenCoordinator.swift in Sources */, + A2937A97959A0AFB064BE1DE /* SettingsScreenHook.swift in Sources */, B93D7CE520088AD53FA6D53C /* SettingsScreenModels.swift in Sources */, 5E67990F98AF11563EA50080 /* SettingsScreenUserStatusPickerView.swift in Sources */, B74CCA58911A0C9C3E277A6B /* SettingsScreenUserStatusRow.swift in Sources */, diff --git a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings index 48704078d..70c4f8520 100644 --- a/ElementX/Resources/Localizations/de.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/de.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_complete_profile" = "Profil vervollständigen"; "action_refresh" = "Aktualisieren"; "common_academic_title" = "Titel"; "common_address" = "Adresse"; @@ -11,6 +12,7 @@ "common_last_name" = "Nachname"; "common_postal_code" = "PLZ"; "common_practice_name" = "Praxisname / Firmenname"; +"common_profile_incomplete" = "Profil unvollständig"; "common_specialty" = "Fachgebiet"; "common_street" = "Strasse"; "common_website" = "Webseite"; diff --git a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings index 84ddcee3e..358845e96 100644 --- a/ElementX/Resources/Localizations/en.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/en.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_complete_profile" = "Complete profile"; "action_refresh" = "Refresh"; "common_academic_title" = "Title"; "common_address" = "Address"; @@ -11,6 +12,7 @@ "common_last_name" = "Last name"; "common_postal_code" = "Postal code"; "common_practice_name" = "Practice name"; +"common_profile_incomplete" = "Profile incomplete"; "common_specialty" = "Speciality"; "common_street" = "Street"; "common_website" = "Website"; diff --git a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings index 3542be620..0bc294ec2 100644 --- a/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings +++ b/ElementX/Resources/Localizations/fr.lproj/HealthChat.strings @@ -1,4 +1,5 @@ // HealthChat fork strings. Not managed by Localazy — edit all three languages by hand. +"action_complete_profile" = "Compléter le profil"; "action_refresh" = "Actualiser"; "common_academic_title" = "Titre"; "common_address" = "Adresse"; @@ -11,6 +12,7 @@ "common_last_name" = "Nom"; "common_postal_code" = "Code postal"; "common_practice_name" = "Nom de l’entreprise"; +"common_profile_incomplete" = "Profil incomplet"; "common_specialty" = "Spécialisation"; "common_street" = "Rue"; "common_website" = "Site web"; diff --git a/ElementX/Sources/AppHooks/AppHooks.swift b/ElementX/Sources/AppHooks/AppHooks.swift index 027dfbf2e..a98a17c67 100644 --- a/ElementX/Sources/AppHooks/AppHooks.swift +++ b/ElementX/Sources/AppHooks/AppHooks.swift @@ -15,6 +15,7 @@ final nonisolated class AppHooks: AppHooksProtocol { func configure(with userSession: UserSessionProtocol?) async { await userSessionHook.configure(with: userSession) await extendedProfileSectionHook.configure(with: userSession) + await settingsScreenHook.configure(with: userSession) } @AppHook(default: DefaultAppSettingsHook()) @@ -50,6 +51,9 @@ final nonisolated class AppHooks: AppHooksProtocol { @AppHook(default: DefaultHomeTabsHook()) var homeTabsHook: HomeTabsHookProtocol + @AppHook(default: DefaultSettingsScreenHook()) + var settingsScreenHook: SettingsScreenHookProtocol + @AppHook(default: DefaultUserDetailsEditScreenHook()) var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift new file mode 100644 index 000000000..154239fb0 --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift @@ -0,0 +1,53 @@ +// +// 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 +import Observation + +/// Loads the own account's status for the banners that advertise it. +@Observable +final class HealthChatAccountStatusLoader { + private(set) var status: HealthChatAccountStatus = .unknown + + /// The load kicked off on creation — await this in tests before asserting. + @ObservationIgnored private(set) var loadingTask: Task? + + init(apiService: HealthChatAPIServiceProtocol) { + loadingTask = Task { await load(from: apiService) } + } + + /// Creates a loader with a fixed status, for previews. + init(status: HealthChatAccountStatus) { + self.status = status + } + + // MARK: - Private + + private func load(from apiService: HealthChatAPIServiceProtocol) async { + // Every failure below leaves the status unknown: a banner must not report an error. + guard case let .success(profile) = await apiService.ownProfile() else { + return + } + + guard profile.isApproved else { + status = .pendingApproval + return + } + + guard case let .success(completeness) = await apiService.profileCompleteness() else { + return + } + + // The server words the hint itself; without one there is nothing to show. + guard !completeness.isComplete, let message = completeness.message else { + status = .ok + return + } + + status = .incompleteProfile(message: message) + } +} diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift new file mode 100644 index 000000000..2c3b4d75e --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift @@ -0,0 +1,67 @@ +// +// 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 + +/// The account status advertised in the app's settings. Renders nothing while loading, +/// for approved accounts with a complete profile, and when the status can't be read. +/// +/// Neither banner is dismissible — both resolve server-side. +struct HealthChatAccountStatusSection: View { + // Owned by the hook, not the view: the settings screen rebuilds this section on + // every body pass, and a view-owned loader would fire a request each time. + private let loader: HealthChatAccountStatusLoader + private let completeProfileAction: () -> Void + + init(loader: HealthChatAccountStatusLoader, completeProfileAction: @escaping () -> Void = { }) { + self.loader = loader + self.completeProfileAction = completeProfileAction + } + + var body: some View { + switch loader.status { + case .unknown, .ok: + EmptyView() + case .pendingApproval: + Section { + ListRow(label: .default(title: HealthChatL10n.screenMemberDirectoryPendingTitle, + description: HealthChatL10n.screenMemberDirectoryPendingApproval, + icon: \.time), + kind: .label) + } + case .incompleteProfile(let message): + Section { + ListRow(label: .default(title: HealthChatL10n.commonProfileIncomplete, + description: message, + icon: \.info), + kind: .label) + + ListRow(label: .action(title: HealthChatL10n.actionCompleteProfile, icon: \.userProfile), + kind: .button(action: completeProfileAction)) + } + } + } +} + +// MARK: - Previews + +struct HealthChatAccountStatusSection_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Form { + HealthChatAccountStatusSection(loader: .init(status: .pendingApproval)) + } + .compoundList() + .previewDisplayName("Pending approval") + + Form { + HealthChatAccountStatusSection(loader: .init(status: .incompleteProfile(message: "Bitte vervollständigen Sie Ihr Profil, bevor Sie die Benutzersuche verwenden"))) + } + .compoundList() + .previewDisplayName("Incomplete profile") + } +} diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAppHooks.swift index 59de8cae9..b72ba6454 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()) + registerSettingsScreenHook(HealthChatSettingsScreenHook()) registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook()) #endif } diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift new file mode 100644 index 000000000..d4cca80b4 --- /dev/null +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift @@ -0,0 +1,36 @@ +// +// 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 SwiftUI + +#if IS_MAIN_APP +// Conforming to the nonisolated hook protocol would infer nonisolated, but the +// service is main-actor state — the hook's requirements are all @MainActor anyway. +@MainActor +final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol { + /// One load per session — the settings screen asks for this section on every body pass. + private var statusLoader: HealthChatAccountStatusLoader? + + // Constructed during hook registration, before the main actor is reachable. + nonisolated init() { } + + func configure(with userSession: UserSessionProtocol?) async { + statusLoader = userSession.map { + let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: $0.clientProxy)) + return HealthChatAccountStatusLoader(apiService: apiService) + } + } + + func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { + guard let statusLoader else { + return nil + } + return AnyView(HealthChatAccountStatusSection(loader: statusLoader, + completeProfileAction: completeProfileAction)) + } +} +#endif diff --git a/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift b/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift new file mode 100644 index 000000000..e176150e7 --- /dev/null +++ b/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.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 SwiftUI + +nonisolated protocol SettingsScreenHookProtocol: Sendable { + @MainActor func configure(with userSession: UserSessionProtocol?) async + + /// An extra Form section rendered between the user and the account sections, + /// advertising whatever is holding the account back. + /// - Parameter completeProfileAction: Opens the own profile editor. + @MainActor func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? +} + +struct DefaultSettingsScreenHook: SettingsScreenHookProtocol { + func configure(with userSession: UserSessionProtocol?) async { } + + func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { + nil + } +} diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index 0ddeaffef..bd6c72237 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -74,6 +74,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { private func presentSettingsScreen(animated: Bool) { let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: flowParameters.userSession, appSettings: flowParameters.appSettings, + appHooks: flowParameters.appHooks, isBugReportServiceEnabled: flowParameters.bugReportService.isEnabled, isInSecondaryWindow: isInSecondaryWindow, userIndicatorController: flowParameters.userIndicatorController)) diff --git a/ElementX/Sources/Generated/Strings+HealthChat.swift b/ElementX/Sources/Generated/Strings+HealthChat.swift index 367cbe15b..6cdf68f05 100644 --- a/ElementX/Sources/Generated/Strings+HealthChat.swift +++ b/ElementX/Sources/Generated/Strings+HealthChat.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal nonisolated enum HealthChatL10n { + /// Complete profile + internal static var actionCompleteProfile: String { return HealthChatL10n.tr("HealthChat", "action_complete_profile") } /// Refresh internal static var actionRefresh: String { return HealthChatL10n.tr("HealthChat", "action_refresh") } /// Title @@ -34,6 +36,8 @@ internal nonisolated enum HealthChatL10n { 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") } + /// Profile incomplete + internal static var commonProfileIncomplete: String { return HealthChatL10n.tr("HealthChat", "common_profile_incomplete") } /// Speciality internal static var commonSpecialty: String { return HealthChatL10n.tr("HealthChat", "common_specialty") } /// Street diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index e5ca5048a..01ef4a908 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -55,6 +55,7 @@ enum TestablePreviewsDictionary { "FormattingToolbar_Previews" : FormattingToolbar_Previews.self, "FullscreenDialog_Previews" : FullscreenDialog_Previews.self, "GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self, + "HealthChatAccountStatusSection_Previews" : HealthChatAccountStatusSection_Previews.self, "HealthChatExtendedProfileSection_Previews" : HealthChatExtendedProfileSection_Previews.self, "HealthChatInviteMemberScreen_Previews" : HealthChatInviteMemberScreen_Previews.self, "HealthChatMemberDirectoryScreen_Previews" : HealthChatMemberDirectoryScreen_Previews.self, diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index 0f0b99669..acd203828 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -11,6 +11,8 @@ import SwiftUI struct SettingsScreenCoordinatorParameters { let userSession: UserSessionProtocol + let appSettings: AppSettings + let appHooks: AppHooks let isBugReportServiceEnabled: Bool let isInSecondaryWindow: Bool let userIndicatorController: UserIndicatorControllerProtocol @@ -18,30 +20,22 @@ struct SettingsScreenCoordinatorParameters { enum SettingsScreenCoordinatorAction { case dismiss - + case logout + case secureBackup case userDetails case userStatusEmojiPicker(EmojiPickerScreenContinuation) - - case addAccount - - case manageAccount(url: URL) - case linkNewDevice - case notifications - case secureBackup - case moderationAndSafety - case bugReport - - case logout - case deactivateAccount - - case mediaUploadQuality - case appLock - case locationSharing case analytics - case labs + case appLock + case bugReport case about - + case blockedUsers + case linkNewDevice + case manageAccount(url: URL) + case notifications + case advancedSettings + case labs case developerOptions + case deactivateAccount } final class SettingsScreenCoordinator: CoordinatorProtocol { @@ -58,6 +52,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { init(parameters: SettingsScreenCoordinatorParameters) { viewModel = SettingsScreenViewModel(userSession: parameters.userSession, + appSettings: parameters.appSettings, + appHooks: parameters.appHooks, isBugReportServiceEnabled: parameters.isBugReportServiceEnabled, isInSecondaryWindow: parameters.isInSecondaryWindow, userIndicatorController: parameters.userIndicatorController) @@ -69,48 +65,38 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { switch action { case .close: actionsSubject.send(.dismiss) - case .userDetails: actionsSubject.send(.userDetails) case let .userStatusEmojiPicker(continuation): actionsSubject.send(.userStatusEmojiPicker(continuation)) - - case .addAccount: - actionsSubject.send(.addAccount) - - case let .manageAccount(url): - actionsSubject.send(.manageAccount(url: url)) case .linkNewDevice: actionsSubject.send(.linkNewDevice) - case .notifications: - actionsSubject.send(.notifications) - case .secureBackup: - actionsSubject.send(.secureBackup) - case .moderationAndSafety: - actionsSubject.send(.moderationAndSafety) + case let .manageAccount(url): + actionsSubject.send(.manageAccount(url: url)) + case .analytics: + actionsSubject.send(.analytics) + case .appLock: + actionsSubject.send(.appLock) case .reportBug: actionsSubject.send(.bugReport) - + case .about: + actionsSubject.send(.about) + case .blockedUsers: + actionsSubject.send(.blockedUsers) + case .secureBackup: + actionsSubject.send(.secureBackup) + case .notifications: + actionsSubject.send(.notifications) + case .advancedSettings: + actionsSubject.send(.advancedSettings) + case .labs: + actionsSubject.send(.labs) + case .developerOptions: + actionsSubject.send(.developerOptions) case .logout: actionsSubject.send(.logout) case .deactivateAccount: actionsSubject.send(.deactivateAccount) - - case .mediaUploadQuality: - actionsSubject.send(.mediaUploadQuality) - case .appLock: - actionsSubject.send(.appLock) - case .locationSharing: - actionsSubject.send(.locationSharing) - case .analytics: - actionsSubject.send(.analytics) - case .labs: - actionsSubject.send(.labs) - case .about: - actionsSubject.send(.about) - - case .developerOptions: - actionsSubject.send(.developerOptions) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 74fc479d7..598f4de28 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -10,30 +10,22 @@ import SwiftUI enum SettingsScreenViewModelAction { case close - case userDetails case userStatusEmojiPicker(EmojiPickerScreenContinuation) - - case addAccount - - case manageAccount(url: URL) case linkNewDevice - case notifications - case secureBackup - case moderationAndSafety + case manageAccount(url: URL) + case analytics + case appLock case reportBug - + case about + case blockedUsers + case secureBackup + case notifications + case advancedSettings + case labs + case developerOptions case logout case deactivateAccount - - case mediaUploadQuality - case appLock - case locationSharing - case analytics - case labs - case about - - case developerOptions } enum SettingsScreenSecuritySectionMode { @@ -46,7 +38,6 @@ struct SettingsScreenViewState: BindableState { var userProfile: UserProfile var showUserStatusInput = false var showLinkNewDeviceButton: Bool - var showAddAccountButton: Bool var accountProfileURL: URL? var showAccountDeactivation: Bool var showDeveloperOptions: Bool @@ -54,13 +45,16 @@ struct SettingsScreenViewState: BindableState { var securitySectionMode = SettingsScreenSecuritySectionMode.none var showSecuritySectionBadge = false + var showBlockedUsers = false let showAnalyticsSettings: Bool let isBugReportServiceEnabled: Bool let navigationBarVisibility: Visibility - var bindings: SettingsScreenViewStateBindings + let appHooks: AppHooks + + var bindings = SettingsScreenViewStateBindings() var userStatusRowMode: SettingsScreenUserStatusRow.Mode { if bindings.isShowingCustomStatusField { @@ -74,8 +68,6 @@ struct SettingsScreenViewState: BindableState { } struct SettingsScreenViewStateBindings { - private let userSettings: UserSettings - var isPresentingStatusPicker = false var customStatusEmoji: Character = "😄" var isShowingCustomStatusField = false { @@ -87,44 +79,27 @@ struct SettingsScreenViewStateBindings { } var isPresentingAccountDeactivationConfirmation = false - - var appAppearance: AppAppearance { - get { userSettings.app.appAppearance } - set { userSettings.app.appAppearance = newValue } - } - - init(userSettings: UserSettings) { - self.userSettings = userSettings - } } enum SettingsScreenViewAction { case close - case userDetails case userStatus(UserStatusAction) - - case addAccount - - case manageAccount(url: URL) - case linkNewDevice - case notifications - case secureBackup - case moderationAndSafety - case reportBug - - case logout - case deactivateAccount - - case mediaUploadQuality - case appLock - case locationSharing case analytics - case labs + case appLock + case reportBug case about - + case blockedUsers + case secureBackup + case linkNewDevice + case manageAccount(url: URL) + case notifications case enableDeveloperOptions case developerOptions + case advancedSettings + case labs + case logout + case deactivateAccount enum UserStatusAction { /// Show status picker sheet to select a preset status. diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index 540cbdcf8..f5c0b1d20 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -12,7 +12,7 @@ import SwiftUI typealias SettingsScreenViewModelType = StateStoreViewModelV2 class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol { - private let userSettings: UserSettings + private let appSettings: AppSettings private let clientProxy: ClientProxyProtocol private let userIndicatorController: UserIndicatorControllerProtocol @@ -22,35 +22,30 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo actionsSubject.eraseToAnyPublisher() } - init(userSession: UserSessionProtocol, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) { - userSettings = userSession.userSettings + init(userSession: UserSessionProtocol, appSettings: AppSettings, appHooks: AppHooks, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) { + self.appSettings = appSettings clientProxy = userSession.clientProxy self.userIndicatorController = userIndicatorController super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID, userProfile: userSession.clientProxy.userProfilePublisher.value, - showLinkNewDeviceButton: userSettings.app.linkNewDeviceEnabled, - showAddAccountButton: userSettings.app.multiAccountEnabled, + showLinkNewDeviceButton: appSettings.linkNewDeviceEnabled, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, - showDeveloperOptions: userSettings.app.developerOptionsEnabled, - showAnalyticsSettings: userSettings.app.canPromptForAnalytics, + showDeveloperOptions: appSettings.developerOptionsEnabled, + showAnalyticsSettings: appSettings.canPromptForAnalytics, isBugReportServiceEnabled: isBugReportServiceEnabled, navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic, - bindings: .init(userSettings: userSettings)), + appHooks: appHooks), mediaProvider: userSession.mediaProvider) - userSettings.app.developerOptionsEnabledPublisher + appSettings.developerOptionsEnabledPublisher .weakAssign(to: \.state.showDeveloperOptions, on: self) .store(in: &cancellables) - userSettings.app.linkNewDeviceEnabledPublisher + appSettings.linkNewDeviceEnabledPublisher .weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .store(in: &cancellables) - userSettings.app.multiAccountEnabledPublisher - .weakAssign(to: \.state.showAddAccountButton, on: self) - .store(in: &cancellables) - userSession.clientProxy.userProfilePublisher .receive(on: DispatchQueue.main) .weakAssign(to: \.state.userProfile, on: self) @@ -78,6 +73,18 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo } .store(in: &cancellables) + userSession.clientProxy.ignoredUsersPublisher + .receive(on: DispatchQueue.main) + .map { + guard let blockedUsers = $0 else { + return false + } + + return !blockedUsers.isEmpty + } + .weakAssign(to: \.state.showBlockedUsers, on: self) + .store(in: &cancellables) + Task { if case .success(true) = await userSession.clientProxy.isUserStatusSupported() { state.showUserStatusInput = true @@ -91,7 +98,6 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo switch viewAction { case .close: actionsSubject.send(.close) - case .userDetails: actionsSubject.send(.userDetails) case .userStatus(.pickStatus): @@ -108,45 +114,36 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo case .userStatus(.cancel): state.bindings.isPresentingStatusPicker = false state.bindings.isShowingCustomStatusField = false - - case .addAccount: - actionsSubject.send(.addAccount) - - case let .manageAccount(url): - actionsSubject.send(.manageAccount(url: url)) case .linkNewDevice: actionsSubject.send(.linkNewDevice) - case .notifications: - actionsSubject.send(.notifications) - case .secureBackup: - actionsSubject.send(.secureBackup) - case .moderationAndSafety: - actionsSubject.send(.moderationAndSafety) - case .reportBug: - actionsSubject.send(.reportBug) - - case .logout: - actionsSubject.send(.logout) - case .deactivateAccount: - actionsSubject.send(.deactivateAccount) - - case .mediaUploadQuality: - actionsSubject.send(.mediaUploadQuality) - case .appLock: - actionsSubject.send(.appLock) - case .locationSharing: - actionsSubject.send(.locationSharing) + case let .manageAccount(url): + actionsSubject.send(.manageAccount(url: url)) case .analytics: actionsSubject.send(.analytics) - case .labs: - actionsSubject.send(.labs) + case .appLock: + actionsSubject.send(.appLock) + case .reportBug: + actionsSubject.send(.reportBug) case .about: actionsSubject.send(.about) - + case .blockedUsers: + actionsSubject.send(.blockedUsers) + case .logout: + actionsSubject.send(.logout) + case .secureBackup: + actionsSubject.send(.secureBackup) + case .notifications: + actionsSubject.send(.notifications) + case .advancedSettings: + actionsSubject.send(.advancedSettings) + case .labs: + actionsSubject.send(.labs) case .enableDeveloperOptions: - userSettings.app.developerOptionsEnabled.toggle() + appSettings.developerOptionsEnabled.toggle() case .developerOptions: actionsSubject.send(.developerOptions) + case .deactivateAccount: + actionsSubject.send(.deactivateAccount) } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index da1f8600b..dee5164db 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -13,19 +13,37 @@ import SwiftUI struct SettingsScreen: View { @Bindable var context: SettingsScreenViewModel.Context + private var shouldHideManageAccountSection: Bool { + context.viewState.accountProfileURL == nil && + !context.viewState.showBlockedUsers && + !context.viewState.showLinkNewDeviceButton + } + var body: some View { Form { userSection - if context.viewState.showAddAccountButton { - addAccountSection + if context.viewState.showUserStatusInput { + userStatusSection } - accountSettingsSection + context.viewState.appHooks.settingsScreenHook.accountStatusSection { + context.send(viewAction: .userDetails) + } + + if !shouldHideManageAccountSection { + manageAccountSection + } + + manageMyAppSection + + generalSection signOutSection - appSettingsSection + if context.viewState.showDeveloperOptions { + developerOptionsSection + } } .compoundList() .navigationTitle(L10n.commonSettings) @@ -56,9 +74,15 @@ struct SettingsScreen: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(context.viewState.userProfile.displayName ?? "") - .font(.compound.headingMD) - .foregroundColor(.compound.textPrimary) + HStack(spacing: 6) { + Text(context.viewState.userProfile.displayName ?? "") + + if let statusEmoji = context.viewState.userProfile.status.displayed?.emoji { + Text(String(statusEmoji)) + } + } + .font(.compound.headingMD) + .foregroundColor(.compound.textPrimary) Text(context.viewState.userProfile.id) .font(.compound.bodySM) @@ -73,27 +97,47 @@ struct SettingsScreen: View { .padding(.vertical, 8) } }) - - if context.viewState.showUserStatusInput { - SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in - context.send(viewAction: .userStatus(action)) - } + } + } + + private var userStatusSection: some View { + Section { + SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in + context.send(viewAction: .userStatus(action)) } } } - private var addAccountSection: some View { + private var manageMyAppSection: some View { Section { - ListRow(label: .default(title: L10n.screenSettingsAddAccount, - icon: \.plus), + ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, + icon: \.notifications), kind: .navigationLink { - context.send(viewAction: .addAccount) + context.send(viewAction: .notifications) }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.addAccount) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications) + + ListRow(label: .default(title: L10n.commonScreenLock, + icon: \.lock), + kind: .navigationLink { + context.send(viewAction: .appLock) + }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock) + + switch context.viewState.securitySectionMode { + case .secureBackup: + ListRow(label: .default(title: L10n.commonEncryption, + icon: \.key), + details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil, + kind: .navigationLink { context.send(viewAction: .secureBackup) }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup) + default: + EmptyView() + } } } - private var accountSettingsSection: some View { + private var manageAccountSection: some View { Section { if let url = context.viewState.accountProfileURL { ListRow(label: .default(title: L10n.actionManageAccountAndDevices, @@ -112,29 +156,38 @@ struct SettingsScreen: View { }) } - ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, - icon: \.notifications), - kind: .navigationLink { - context.send(viewAction: .notifications) - }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications) - - switch context.viewState.securitySectionMode { - case .secureBackup: - ListRow(label: .default(title: L10n.commonEncryption, - icon: \.key), - details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil, - kind: .navigationLink { context.send(viewAction: .secureBackup) }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup) - default: - EmptyView() + if context.viewState.showBlockedUsers { + ListRow(label: .default(title: L10n.commonBlockedUsers, + icon: \.block), + kind: .navigationLink { + context.send(viewAction: .blockedUsers) + }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.blockedUsers) } - - ListRow(label: .default(title: L10n.commonModerationAndSafety, - icon: \.admin), + } + } + + private var generalSection: some View { + Section { + ListRow(label: .default(title: L10n.commonAdvancedSettings, + icon: \.settings), kind: .navigationLink { - context.send(viewAction: .moderationAndSafety) + context.send(viewAction: .advancedSettings) }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.advancedSettings) + + ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs, + icon: \.labs), + kind: .navigationLink { + context.send(viewAction: .labs) + }) + + ListRow(label: .default(title: L10n.commonAbout, + icon: \.info), + kind: .navigationLink { + context.send(viewAction: .about) + }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.about) if context.viewState.isBugReportServiceEnabled { ListRow(label: .default(title: L10n.commonReportAProblem, @@ -144,9 +197,15 @@ struct SettingsScreen: View { }) .accessibilityIdentifier(A11yIdentifiers.settingsScreen.reportBug) } - } header: { - Text(L10n.commonAccountSettings) - .compoundListSectionHeader() + + if context.viewState.showAnalyticsSettings { + ListRow(label: .default(title: L10n.commonAnalytics, + icon: \.chart), + kind: .navigationLink { + context.send(viewAction: .analytics) + }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics) + } } } @@ -168,68 +227,21 @@ struct SettingsScreen: View { context.send(viewAction: .deactivateAccount) }) } + } footer: { + if !context.viewState.showDeveloperOptions { + versionSection + } } } - private var appSettingsSection: some View { + private var developerOptionsSection: some View { Section { - ListRow(label: .default(title: L10n.commonAppearance, - icon: \.darkMode), - kind: .picker(selection: $context.appAppearance, - items: AppAppearance.allCases.map { (title: $0.name, tag: $0) })) - - ListRow(label: .default(title: L10n.commonMediaUploadQuality, - icon: \.image), + ListRow(label: .default(title: L10n.commonDeveloperOptions, + icon: \.code), kind: .navigationLink { - context.send(viewAction: .mediaUploadQuality) + context.send(viewAction: .developerOptions) }) - - ListRow(label: .default(title: L10n.commonScreenLock, - icon: \.lock), - kind: .navigationLink { - context.send(viewAction: .appLock) - }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock) - - ListRow(label: .default(title: L10n.commonLocationSharing, - icon: \.locationPin), - kind: .navigationLink { - context.send(viewAction: .locationSharing) - }) - - if context.viewState.showAnalyticsSettings { - ListRow(label: .default(title: L10n.commonAnalytics, - icon: \.chart), - kind: .navigationLink { - context.send(viewAction: .analytics) - }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics) - } - - ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs, - icon: \.labs), - kind: .navigationLink { - context.send(viewAction: .labs) - }) - - ListRow(label: .default(title: L10n.commonAbout, - icon: \.info), - kind: .navigationLink { - context.send(viewAction: .about) - }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.about) - - if context.viewState.showDeveloperOptions { - ListRow(label: .default(title: L10n.commonDeveloperOptions, - icon: \.code), - kind: .navigationLink { - context.send(viewAction: .developerOptions) - }) - .accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions) - } - } header: { - Text(L10n.commonAppSettings) - .compoundListSectionHeader() + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions) } footer: { versionSection } @@ -274,19 +286,6 @@ struct SettingsScreen: View { } } -private extension AppAppearance { - var name: String { - switch self { - case .system: - L10n.themeSystem - case .light: - L10n.themeLight - case .dark: - L10n.themeDark - } - } -} - // MARK: - Previews @available(iOS 26.0, *) @@ -299,7 +298,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { SettingsScreen(context: viewModel.context) } .snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) - .frame(height: 1300) + .frame(height: 1100) .previewLayout(.sizeThatFits) .previewDisplayName("Default") @@ -307,21 +306,19 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { SettingsScreen(context: bugReportDisabledViewModel.context) } .snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) - .frame(height: 1250) + .frame(height: 1050) .previewLayout(.sizeThatFits) .previewDisplayName("Bug report disabled") } static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> SettingsScreenViewModel { - let userSettings = UserSettings.mock() - userSettings.app.multiAccountEnabled = true - userSettings.app.linkNewDeviceEnabled = true let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@alice:example.com", deviceID: "AAAAAAAAAAA", displayName: "Alice Liddell", - status: .mockFocussing)), - userSettings: userSettings)) + status: .mockFocussing)))) return SettingsScreenViewModel(userSession: userSession, + appSettings: .volatile(), + appHooks: AppHooks(), isBugReportServiceEnabled: isBugReportServiceEnabled, isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) diff --git a/ElementX/Sources/Services/HealthChat/HealthChatAccountStatus.swift b/ElementX/Sources/Services/HealthChat/HealthChatAccountStatus.swift new file mode 100644 index 000000000..c8c8a4e1b --- /dev/null +++ b/ElementX/Sources/Services/HealthChat/HealthChatAccountStatus.swift @@ -0,0 +1,20 @@ +// +// 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 + +/// What is holding an account back from using the whole app. +nonisolated enum HealthChatAccountStatus: Equatable { + /// Nothing is known, e.g. the API is unreachable. Renders nothing — never an error. + case unknown + /// Not approved by an admin yet. Takes precedence over ``incompleteProfile``, + /// as completing a profile does nothing for an unapproved account. + case pendingApproval + /// Approved, but the server reports missing profile fields. The message is its wording. + case incompleteProfile(message: String) + case ok +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index b466f488d..e44f68697 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -333,6 +333,13 @@ extension PreviewTests { } } + @Test + func healthChatAccountStatusSection() async throws { + for (index, preview) in HealthChatAccountStatusSection_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func healthChatExtendedProfileSection() async throws { for (index, preview) in HealthChatExtendedProfileSection_Previews._allPreviews.enumerated() { diff --git a/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift b/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift new file mode 100644 index 000000000..af22fa22a --- /dev/null +++ b/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift @@ -0,0 +1,83 @@ +// +// 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. +// + +@testable import ElementX +import Foundation +import Testing + +final class HealthChatAccountStatusLoaderTests { + private let apiService = HealthChatAPIServiceMock() + + @Test + func reportsPendingApprovalForAnUnapprovedAccount() async throws { + apiService.ownProfileReturnValue = try .success(makeMember(isApproved: false)) + + #expect(await status() == .pendingApproval) + #expect(apiService.profileCompletenessCallsCount == 0) + } + + @Test + func reportsTheIncompleteProfileMessage() async throws { + apiService.ownProfileReturnValue = try .success(makeMember(isApproved: true)) + apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: false, + message: "Profil unvollständig")) + + #expect(await status() == .incompleteProfile(message: "Profil unvollständig")) + } + + @Test + func reportsOKForAnApprovedAccountWithACompleteProfile() async throws { + apiService.ownProfileReturnValue = try .success(makeMember(isApproved: true)) + apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: true, message: nil)) + + #expect(await status() == .ok) + } + + @Test + func reportsOKWhenTheServerWordsNoHint() async throws { + apiService.ownProfileReturnValue = try .success(makeMember(isApproved: true)) + apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: false, message: nil)) + + #expect(await status() == .ok) + } + + @Test + func staysUnknownWhenTheProfileCannotBeRead() async { + apiService.ownProfileReturnValue = .failure(.networkFailure) + + #expect(await status() == .unknown) + } + + @Test + func staysUnknownWhenCompletenessCannotBeRead() async throws { + apiService.ownProfileReturnValue = try .success(makeMember(isApproved: true)) + apiService.profileCompletenessReturnValue = .failure(.networkFailure) + + #expect(await status() == .unknown) + } + + // MARK: - Helpers + + private func status() async -> HealthChatAccountStatus { + let loader = HealthChatAccountStatusLoader(apiService: apiService) + await loader.loadingTask?.value + return loader.status + } + + private func makeMember(isApproved: Bool) throws -> HealthChatMember { + try JSONDecoder().decode(HealthChatMember.self, from: Data(""" + { "userId": "@alice:healthchat.ch", "isApproved": \(isApproved) } + """.utf8)) + } + + 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)) + } +} diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index 16da6c2ce..7e0bb689a 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -172,37 +172,13 @@ struct SettingsScreenViewModelTests { try await deferred.fulfill() } - // MARK: - Add account - - @Test - mutating func addAccountButtonFollowsTheFeatureFlag() async throws { - let userSettings = UserSettings.mock() - setupViewModel(userSettings: userSettings) - #expect(!context.viewState.showAddAccountButton) - - var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 } - userSettings.app.multiAccountEnabled = true - try await deferred.fulfill() - - deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 } - userSettings.app.multiAccountEnabled = false - try await deferred.fulfill() - } - - @Test - mutating func addAccount() async throws { - setupViewModel() - - let deferred = deferFulfillment(viewModel.actions) { $0.isAddAccount } - context.send(viewAction: .addAccount) - try await deferred.fulfill() - } - // MARK: - Helpers - private mutating func setupViewModel(status: UserStatus = .init(), userSettings: UserSettings = .mock()) { + private mutating func setupViewModel(status: UserStatus = .init()) { clientProxy = ClientProxyMock(.init(userID: "", status: status)) - viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)), + viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + appSettings: AppSettings.volatile(), + appHooks: AppHooks(), isBugReportServiceEnabled: true, isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) @@ -251,11 +227,4 @@ private extension SettingsScreenViewModelAction { default: false } } - - var isAddAccount: Bool { - switch self { - case .addAccount: true - default: false - } - } }