feat(healthchat): advertise the account status in settings

Pending approval and an incomplete profile were only visible in the
members tab, which a new user has no reason to open.
This commit is contained in:
Sedat Qaja
2026-10-09 14:39:33 +02:00
parent 181c518dc7
commit d9524130fa
22 changed files with 556 additions and 296 deletions
@@ -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")
}
+24
View File
@@ -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 = "<group>"; };
05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = "<group>"; };
05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = "<group>"; };
056FCFA596B562977CA89D58 /* HealthChatAccountStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusSection.swift; sourceTree = "<group>"; };
057B747CF045D3C6C30EAB2C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fi; path = fi.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = "<group>"; };
@@ -2232,6 +2239,7 @@
46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsAppCoordinator.swift; sourceTree = "<group>"; };
46D0BA44B1838E65B507B277 /* NotificationPermissionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreen.swift; sourceTree = "<group>"; };
46D560DDA3B20C82766ACFAD /* NotificationSettingsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreenViewModel.swift; sourceTree = "<group>"; };
46DE3887AAA265406107ABE6 /* HealthChatAccountStatusLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusLoader.swift; sourceTree = "<group>"; };
47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = "<group>"; };
471BB7276C97AF60B3A5463B /* RoomDirectorySearchProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchProxy.swift; sourceTree = "<group>"; };
475D47D0BFE961B02BAC5D49 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = id; path = id.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -2321,6 +2329,7 @@
55BA4D3DFEA16CDD3FE99064 /* InverseMask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InverseMask.swift; sourceTree = "<group>"; };
5644919DB2022397D9D5825A /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = "<group>"; };
565F1B2B300597C616B37888 /* FullscreenDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenDialog.swift; sourceTree = "<group>"; };
56630FCC9F78E5E8F1A75770 /* SettingsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenHook.swift; sourceTree = "<group>"; };
56852036214ABA9D7D305768 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift; sourceTree = "<group>"; };
56D6F88FE35A0979D2821E06 /* AppLockScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreen.swift; sourceTree = "<group>"; };
57084488B03BDB33C7B7CA0E /* ResolveVerifiedUserSendFailureScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -3127,6 +3136,7 @@
D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreen.swift; sourceTree = "<group>"; };
DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = "<group>"; };
DA2FEFA393FC7D2870263012 /* LiveLocationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSession.swift; sourceTree = "<group>"; };
DA3BFFFEF695209F27535BED /* HealthChatAccountStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatus.swift; sourceTree = "<group>"; };
DA3D82522494E78746B2214E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SAS.strings; sourceTree = "<group>"; };
DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLLocationManagerProtocol.swift; sourceTree = "<group>"; };
DA5CC7FB1CF8C69041B62DD7 /* UserDetailsEditScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenHook.swift; sourceTree = "<group>"; };
@@ -3211,6 +3221,7 @@
E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSelectionToolbar.swift; sourceTree = "<group>"; };
E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTests.swift; sourceTree = "<group>"; };
E851BFC1E8A0E1925D26CD14 /* HealthChatAccountStatusLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatAccountStatusLoaderTests.swift; sourceTree = "<group>"; };
E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = "<group>"; };
E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; };
E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = "<group>"; };
@@ -3250,6 +3261,7 @@
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = "<group>"; };
ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = "<group>"; };
EDAE8AB9A3CBD957EDD666ED /* HealthChatSettingsScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthChatSettingsScreenHook.swift; sourceTree = "<group>"; };
EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceFlowCoordinator.swift; sourceTree = "<group>"; };
EDF6E8208542BE7A02A94939 /* PresentationColorSchemeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationColorSchemeModifier.swift; sourceTree = "<group>"; };
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -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";
@@ -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";
@@ -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";
+4
View File
@@ -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
@@ -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<Void, Never>?
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)
}
}
@@ -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")
}
}
@@ -18,6 +18,7 @@ extension AppHooks {
registerCompoundHook(HealthChatCompoundHook())
registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook())
registerHomeTabsHook(HealthChatHomeTabsHook())
registerSettingsScreenHook(HealthChatSettingsScreenHook())
registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook())
#endif
}
@@ -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
@@ -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
}
}
@@ -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))
@@ -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
@@ -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,
@@ -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)
@@ -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.
@@ -12,7 +12,7 @@ import SwiftUI
typealias SettingsScreenViewModelType = StateStoreViewModelV2<SettingsScreenViewState, SettingsScreenViewAction>
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)
}
}
@@ -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,7 +74,13 @@ struct SettingsScreen: View {
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
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)
@@ -73,27 +97,47 @@ struct SettingsScreen: View {
.padding(.vertical, 8)
}
})
}
}
if context.viewState.showUserStatusInput {
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),
if context.viewState.showBlockedUsers {
ListRow(label: .default(title: L10n.commonBlockedUsers,
icon: \.block),
kind: .navigationLink {
context.send(viewAction: .notifications)
context.send(viewAction: .blockedUsers)
})
.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()
.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),
kind: .navigationLink {
context.send(viewAction: .mediaUploadQuality)
})
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()
} 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())
@@ -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
}
@@ -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() {
@@ -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))
}
}
@@ -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
}
}
}