diff --git a/features/healthchat/api/build.gradle.kts b/features/healthchat/api/build.gradle.kts index 9c5cf852f9..c19ffd5d94 100644 --- a/features/healthchat/api/build.gradle.kts +++ b/features/healthchat/api/build.gradle.kts @@ -7,6 +7,7 @@ */ plugins { id("io.element.android-compose-library") + id("kotlin-parcelize") } android { @@ -16,7 +17,7 @@ android { dependencies { api(libs.kotlinx.collections.immutable) implementation(libs.coroutines.core) - implementation(projects.libraries.architecture) + api(projects.libraries.architecture) implementation(projects.libraries.designsystem) api(projects.libraries.matrix.api) } diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt new file mode 100644 index 0000000000..678325cb7e --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatAccountStatusRenderer.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Renders the HealthChat account status banners ("account under review", "profile incomplete"). + * + * Both states resolve server-side (approval, completed profile), so the banners are + * deliberately not dismissible. When nothing needs attention, nothing is rendered. + */ +fun interface HealthChatAccountStatusRenderer { + /** + * @param onCompleteProfileClick called when the user taps the banner action to complete their profile. + * @param includePendingApproval false when the host screen already shows its own + * pending-approval banner (e.g. the member directory with the verbatim server message). + * @param modifier the modifier to apply to the banner. + */ + @Composable + fun Render( + onCompleteProfileClick: () -> Unit, + includePendingApproval: Boolean, + modifier: Modifier, + ) +} diff --git a/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt new file mode 100644 index 0000000000..4d671d1adc --- /dev/null +++ b/features/healthchat/api/src/main/kotlin/io/element/android/features/healthchat/api/HealthChatEntryPoint.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.api + +import android.os.Parcelable +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize + +/** + * Entry point into the fork-owned HealthChat flow (profile editor, member invitation). + */ +fun interface HealthChatEntryPoint : FeatureEntryPoint { + data class Params( + val initialTarget: InitialTarget, + ) : NodeInputs + + sealed interface InitialTarget : Parcelable { + @Parcelize + data object EditOwnProfile : InitialTarget + + @Parcelize + data object Invite : InitialTarget + } + + interface Callback : Plugin { + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node +} diff --git a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml index ec6ffe282b..1b05cc7c04 100644 --- a/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-de/strings_healthchat.xml @@ -37,4 +37,46 @@ Aktualisieren Profil bearbeiten Neues Mitglied einladen + Anzeigename + Titel + z.B. Dr. med. + Vorname + Nachname + z.B. Allgemeinmedizin + z.B. Praxis Muster + Ort + z.B. Zürich + praxis@beispiel.ch + +41 44 123 45 67 + + Strasse + Nr. + PLZ + https://praxis-muster.ch + Profil bearbeiten + Persönliche Angaben + Berufliche Angaben + Kontaktdaten + Praxisadresse + Profil wird gespeichert… + Profil konnte nicht gespeichert werden + Das Profil konnte nicht geladen werden. + Für ein vollständiges Profil empfohlen + Dieses Feld ist erforderlich + Bitte eine gültige E-Mail-Adresse eingeben + Bitte eine gültige URL eingeben (z.B. https://praxis-muster.ch) + Die persönliche Nachricht muss mindestens 10 Zeichen lang sein + Das Bild darf höchstens 2 MB gross sein. + Profil unvollständig + Bitte vervollständigen Sie Ihr Profil, damit andere Mitglieder Sie finden können. Fehlende Angaben: %1$s + Profil vervollständigen + Mitglied einladen + Laden Sie neue Mitglieder zu HealthChat ein, indem Sie deren E-Mail-Adresse und eine persönliche Nachricht eingeben. + E-Mail-Adresse + beispiel@domain.ch + Persönliche Nachricht + Schreiben Sie eine persönliche Nachricht an das neue Mitglied… + %1$d/10 Zeichen + Einladung senden + Einladung wird gesendet… diff --git a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml index 49df77d036..f7c435fb75 100644 --- a/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-fr/strings_healthchat.xml @@ -38,4 +38,45 @@ Actualiser Modifier le profil Inviter un nouveau membre + Nom affiché + Titre + p. ex. Dr méd. + Prénom + Nom + p. ex. médecine générale + p. ex. Praxis Muster + Ville + p. ex. Zurich + cabinet@exemple.ch + +41 44 123 45 67 + Rue + N° + NPA + https://praxis-muster.ch + Modifier le profil + Informations personnelles + Informations professionnelles + Coordonnées + Adresse du cabinet + Enregistrement du profil… + Impossible d’enregistrer le profil + Le profil n’a pas pu être chargé. + Recommandé pour un profil complet + Ce champ est obligatoire + Veuillez saisir une adresse e-mail valide + Veuillez saisir une URL valide (p. ex. https://praxis-muster.ch) + Le message personnel doit contenir au moins 10 caractères + L’image ne doit pas dépasser 2 Mo. + Profil incomplet + Veuillez compléter votre profil pour que les autres membres puissent vous trouver. Informations manquantes : %1$s + Compléter le profil + Inviter un membre + Invitez de nouveaux membres sur HealthChat en saisissant leur adresse e-mail et un message personnel. + Adresse e-mail + exemple@domaine.ch + Message personnel + Écrivez un message personnel au nouveau membre… + %1$d/10 caractères + Envoyer l’invitation + Envoi de l’invitation… diff --git a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml index 20af35a02b..af22c6883a 100644 --- a/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values-it/strings_healthchat.xml @@ -38,4 +38,45 @@ Aggiorna Modifica profilo Invita un nuovo membro + Nome visualizzato + Titolo + ad es. Dr. med. + Nome + Cognome + ad es. medicina generale + ad es. Praxis Muster + Città + ad es. Zurigo + studio@esempio.ch + +41 44 123 45 67 + Via + N. + CAP + https://praxis-muster.ch + Modifica profilo + Dati personali + Dati professionali + Contatti + Indirizzo dello studio + Salvataggio del profilo… + Impossibile salvare il profilo + Impossibile caricare il profilo. + Consigliato per un profilo completo + Questo campo è obbligatorio + Inserisci un indirizzo e-mail valido + Inserisci un URL valido (ad es. https://praxis-muster.ch) + Il messaggio personale deve contenere almeno 10 caratteri + L’immagine non deve superare i 2 MB. + Profilo incompleto + Completa il tuo profilo affinché gli altri membri possano trovarti. Dati mancanti: %1$s + Completa il profilo + Invita un membro + Invita nuovi membri su HealthChat inserendo il loro indirizzo e-mail e un messaggio personale. + Indirizzo e-mail + esempio@dominio.ch + Messaggio personale + Scrivi un messaggio personale al nuovo membro… + %1$d/10 caratteri + Invia invito + Invio dell’invito… diff --git a/features/healthchat/api/src/main/res/values/strings_healthchat.xml b/features/healthchat/api/src/main/res/values/strings_healthchat.xml index f17fbea476..15bdb537bd 100644 --- a/features/healthchat/api/src/main/res/values/strings_healthchat.xml +++ b/features/healthchat/api/src/main/res/values/strings_healthchat.xml @@ -38,4 +38,45 @@ Refresh Edit profile Invite a new member + Display name + Title + e.g. Dr. med. + First name + Last name + e.g. general medicine + e.g. Praxis Muster + City + e.g. Zurich + praxis@example.ch + +41 44 123 45 67 + Street + No. + Postal code + https://praxis-muster.ch + Edit profile + Personal details + Professional details + Contact details + Practice address + Saving profile… + Couldn’t save the profile + The profile couldn’t be loaded. + Recommended for a complete profile + This field is required + Please enter a valid email address + Please enter a valid URL (e.g. https://praxis-muster.ch) + The personal message must be at least 10 characters long + The image must not exceed 2 MB. + Profile incomplete + Please complete your profile so other members can find you. Missing details: %1$s + Complete profile + Invite a member + Invite new members to HealthChat by entering their email address and a personal message. + Email address + example@domain.ch + Personal message + Write a personal message to the new member… + %1$d/10 characters + Send invitation + Sending invitation… diff --git a/features/healthchat/impl/build.gradle.kts b/features/healthchat/impl/build.gradle.kts index 7042791b93..b63e2da36b 100644 --- a/features/healthchat/impl/build.gradle.kts +++ b/features/healthchat/impl/build.gradle.kts @@ -13,6 +13,7 @@ import extension.testCommonDependencies plugins { id("io.element.android-compose-library") + id("kotlin-parcelize") alias(libs.plugins.kotlin.serialization) } @@ -58,7 +59,11 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.network) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) @@ -67,6 +72,9 @@ dependencies { testImplementation(projects.features.healthchat.test) testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.api) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt new file mode 100644 index 0000000000..bff86df033 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/DefaultHealthChatEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.healthchat.api.HealthChatEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultHealthChatEntryPoint : HealthChatEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: HealthChatEntryPoint.Params, + callback: HealthChatEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, plugins = listOf(params, callback)) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt new file mode 100644 index 0000000000..6ebd5eaa1c --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/HealthChatFlowNode.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.healthchat.api.HealthChatEntryPoint +import io.element.android.features.healthchat.impl.invite.HealthChatInviteNode +import io.element.android.features.healthchat.impl.profile.edit.EditHealthChatProfileNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class HealthChatFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = plugins + .filterIsInstance() + .first() + .initialTarget + .toNavTarget(), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object EditOwnProfile : NavTarget + + @Parcelize + data object Invite : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + // The HealthChatEntryPoint.Callback plugin is forwarded so the child screens can + // close the whole flow. + return when (navTarget) { + NavTarget.EditOwnProfile -> createNode(buildContext, plugins = plugins) + NavTarget.Invite -> createNode(buildContext, plugins = plugins) + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} + +private fun HealthChatEntryPoint.InitialTarget.toNavTarget(): HealthChatFlowNode.NavTarget { + return when (this) { + HealthChatEntryPoint.InitialTarget.EditOwnProfile -> HealthChatFlowNode.NavTarget.EditOwnProfile + HealthChatEntryPoint.InitialTarget.Invite -> HealthChatFlowNode.NavTarget.Invite + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt index 492401dd88..f5d5d7dd0f 100644 --- a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/directory/DefaultMemberDirectoryRenderer.kt @@ -8,19 +8,25 @@ package io.element.android.features.healthchat.impl.directory +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.zacsweers.metro.ContributesBinding import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer import io.element.android.features.healthchat.api.directory.MemberDirectoryState +import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusPresenter +import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusView import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @ContributesBinding(SessionScope::class) -class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer { +class DefaultMemberDirectoryRenderer( + private val accountStatusPresenter: HealthChatAccountStatusPresenter, +) : MemberDirectoryRenderer { @Composable override fun Render( state: MemberDirectoryState, @@ -33,16 +39,26 @@ class DefaultMemberDirectoryRenderer : MemberDirectoryRenderer { onInviteClick: (() -> Unit)?, modifier: Modifier, ) { - MemberDirectoryView( - state = state, - contentPadding = contentPadding, - lazyListState = lazyListState, - onOpenDm = onOpenDm, - onMemberClick = onMemberClick, - onMyProfileClick = onMyProfileClick, - onEditProfileClick = onEditProfileClick, - onInviteClick = onInviteClick, - modifier = modifier, - ) + Column(modifier = modifier.fillMaxSize()) { + // The "profile incomplete" advisory; the pending-approval case is excluded because + // the directory itself shows the verbatim server pending message. + if (onEditProfileClick != null) { + HealthChatAccountStatusView( + state = accountStatusPresenter.present(), + onCompleteProfileClick = onEditProfileClick, + includePendingApproval = false, + ) + } + MemberDirectoryView( + state = state, + contentPadding = contentPadding, + lazyListState = lazyListState, + onOpenDm = onOpenDm, + onMemberClick = onMemberClick, + onMyProfileClick = onMyProfileClick, + onEditProfileClick = onEditProfileClick, + onInviteClick = onInviteClick, + ) + } } } diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt new file mode 100644 index 0000000000..8d0265deb8 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +sealed interface HealthChatInviteEvent { + data class UpdateEmail(val email: String) : HealthChatInviteEvent + data class UpdatePersonalNote(val personalNote: String) : HealthChatInviteEvent + data object Send : HealthChatInviteEvent + data object CloseDialog : HealthChatInviteEvent +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt new file mode 100644 index 0000000000..e61df92bbd --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteNode.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.healthchat.api.HealthChatEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class HealthChatInviteNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: HealthChatInvitePresenter, +) : Node(buildContext, plugins = plugins) { + private val callback: HealthChatEntryPoint.Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + HealthChatInviteView( + state = presenter.present(), + onBackClick = callback::onDone, + onInviteSuccess = callback::onDone, + modifier = modifier, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt new file mode 100644 index 0000000000..bff93cbc2b --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatInviteService +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.impl.profile.HealthChatProfileValidator +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.launch + +private const val MIN_NOTE_LENGTH = 10 + +@Inject +class HealthChatInvitePresenter( + private val inviteService: HealthChatInviteService, +) : Presenter { + @Composable + override fun present(): HealthChatInviteState { + val coroutineScope = rememberCoroutineScope() + var email by rememberSaveable { mutableStateOf("") } + var personalNote by rememberSaveable { mutableStateOf("") } + var emailError by remember { mutableStateOf(null) } + var personalNoteError by remember { mutableStateOf(null) } + val sendAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + val canSubmit = email.isNotBlank() && + personalNote.trim().length >= MIN_NOTE_LENGTH && + sendAction.value !is AsyncAction.Loading + + fun validate(): Boolean { + emailError = when { + email.isBlank() -> HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_field_required) + !HealthChatProfileValidator.isValidEmail(email) -> + HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email) + else -> null + } + personalNoteError = when { + personalNote.trim().length < MIN_NOTE_LENGTH -> + HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_note_too_short) + else -> null + } + return emailError == null && personalNoteError == null + } + + fun handleEvents(event: HealthChatInviteEvent) { + when (event) { + is HealthChatInviteEvent.UpdateEmail -> { + email = event.email + emailError = null + } + is HealthChatInviteEvent.UpdatePersonalNote -> { + personalNote = event.personalNote + personalNoteError = null + } + HealthChatInviteEvent.Send -> { + if (!validate()) return + coroutineScope.launch { + sendAction.value = AsyncAction.Loading + inviteService.invite(email = email.trim(), personalNote = personalNote.trim()).fold( + onSuccess = { message -> sendAction.value = AsyncAction.Success(message) }, + onFailure = { error -> + if (error is HealthChatApiException.Validation) { + // Server-side field errors surface under their fields, verbatim. + error.fieldErrors["email"]?.let { + emailError = HealthChatFieldError.Server(it.joinToString("\n")) + } + error.fieldErrors["personalnote"]?.let { + personalNoteError = HealthChatFieldError.Server(it.joinToString("\n")) + } + val unmappedErrors = error.fieldErrors.keys - setOf("email", "personalnote") + if (unmappedErrors.isNotEmpty()) { + sendAction.value = AsyncAction.Failure(error) + } else { + sendAction.value = AsyncAction.Uninitialized + } + } else { + sendAction.value = AsyncAction.Failure(error) + } + }, + ) + } + } + HealthChatInviteEvent.CloseDialog -> sendAction.value = AsyncAction.Uninitialized + } + } + + return HealthChatInviteState( + email = email, + personalNote = personalNote, + emailError = emailError, + personalNoteError = personalNoteError, + canSubmit = canSubmit, + sendAction = sendAction.value, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt new file mode 100644 index 0000000000..59cb84f092 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import androidx.annotation.StringRes +import io.element.android.libraries.architecture.AsyncAction + +/** A form field error: either a localized client-side message or a verbatim server message. */ +sealed interface HealthChatFieldError { + data class Localised(@StringRes val res: Int) : HealthChatFieldError + data class Server(val message: String) : HealthChatFieldError +} + +data class HealthChatInviteState( + val email: String, + val personalNote: String, + val emailError: HealthChatFieldError?, + val personalNoteError: HealthChatFieldError?, + val canSubmit: Boolean, + /** The success value carries the verbatim German server confirmation. */ + val sendAction: AsyncAction, + val eventSink: (HealthChatInviteEvent) -> Unit, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt new file mode 100644 index 0000000000..530054892f --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteStateProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.libraries.architecture.AsyncAction + +open class HealthChatInviteStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHealthChatInviteState(), + aHealthChatInviteState( + email = "neu@beispiel.ch", + personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.", + canSubmit = true, + ), + aHealthChatInviteState( + email = "keine-email", + emailError = HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email), + personalNote = "kurz", + personalNoteError = HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_note_too_short), + ), + aHealthChatInviteState( + email = "neu@beispiel.ch", + personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.", + personalNoteError = HealthChatFieldError.Server("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"), + ), + aHealthChatInviteState( + email = "neu@beispiel.ch", + personalNote = "Willkommen bei HealthChat! Ich lade Sie gerne ein.", + sendAction = AsyncAction.Loading, + ), + ) +} + +fun aHealthChatInviteState( + email: String = "", + personalNote: String = "", + emailError: HealthChatFieldError? = null, + personalNoteError: HealthChatFieldError? = null, + canSubmit: Boolean = false, + sendAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (HealthChatInviteEvent) -> Unit = {}, +) = HealthChatInviteState( + email = email, + personalNote = personalNote, + emailError = emailError, + personalNoteError = personalNoteError, + canSubmit = canSubmit, + sendAction = sendAction, + eventSink = eventSink, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt new file mode 100644 index 0000000000..b07893e13d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInviteView.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.impl.healthChatUserMessage +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HealthChatInviteView( + state: HealthChatInviteState, + onBackClick: () -> Unit, + onInviteSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + titleStr = stringResource(HealthChatStrings.healthchat_screen_invite_title), + navigationIcon = { BackButton(onBackClick) }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(HealthChatStrings.healthchat_screen_invite_description), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(24.dp)) + TextField( + value = state.email, + onValueChange = { state.eventSink(HealthChatInviteEvent.UpdateEmail(it)) }, + label = stringResource(HealthChatStrings.healthchat_screen_invite_email_label), + placeholder = stringResource(HealthChatStrings.healthchat_screen_invite_email_placeholder), + supportingText = state.emailError?.text(), + validity = if (state.emailError != null) TextFieldValidity.Invalid else TextFieldValidity.None, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + TextField( + value = state.personalNote, + onValueChange = { state.eventSink(HealthChatInviteEvent.UpdatePersonalNote(it)) }, + label = stringResource(HealthChatStrings.healthchat_screen_invite_note_label), + placeholder = stringResource(HealthChatStrings.healthchat_screen_invite_note_placeholder), + supportingText = state.personalNoteError?.text() + ?: stringResource(HealthChatStrings.healthchat_screen_invite_note_counter, state.personalNote.trim().length), + validity = if (state.personalNoteError != null) TextFieldValidity.Invalid else TextFieldValidity.None, + minLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + text = stringResource(HealthChatStrings.healthchat_screen_invite_send), + onClick = { + focusManager.clearFocus() + state.eventSink(HealthChatInviteEvent.Send) + }, + enabled = state.canSubmit, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + } + + AsyncActionView( + async = state.sendAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(HealthChatStrings.healthchat_screen_invite_sending), + ) + }, + onSuccess = { onInviteSuccess() }, + errorMessage = { error -> error.healthChatUserMessage() }, + onErrorDismiss = { state.eventSink(HealthChatInviteEvent.CloseDialog) }, + ) + } +} + +@Composable +private fun HealthChatFieldError.text(): String { + return when (this) { + is HealthChatFieldError.Localised -> stringResource(res) + is HealthChatFieldError.Server -> message + } +} + +@PreviewsDayNight +@Composable +internal fun HealthChatInviteViewPreview( + @PreviewParameter(HealthChatInviteStateProvider::class) state: HealthChatInviteState +) = ElementPreview { + HealthChatInviteView( + state = state, + onBackClick = {}, + onInviteSuccess = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt new file mode 100644 index 0000000000..ed56171622 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileField.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile + +import androidx.annotation.StringRes +import androidx.compose.ui.text.input.KeyboardType +import io.element.android.features.healthchat.api.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatProfileUpdate +import io.element.android.features.healthchat.api.HealthChatStrings + +enum class HealthChatFieldRequirement { + /** Blank blocks saving. */ + Required, + + /** Never blocks saving, but the server reports the profile as incomplete without it. */ + RecommendedForCompleteness, + + Optional, +} + +/** + * Descriptor of the 13 editable HealthChat profile fields (order = form order). + */ +enum class HealthChatProfileField( + val apiKey: String, + @StringRes val labelRes: Int, + @StringRes val placeholderRes: Int?, + val maxLength: Int, + val requirement: HealthChatFieldRequirement, + val keyboardType: KeyboardType, + val minLines: Int = 1, +) { + Title( + apiKey = "title", + labelRes = HealthChatStrings.healthchat_field_title, + placeholderRes = HealthChatStrings.healthchat_field_title_placeholder, + maxLength = 50, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Text, + ), + FirstName( + apiKey = "firstName", + labelRes = HealthChatStrings.healthchat_field_first_name, + placeholderRes = null, + maxLength = 100, + requirement = HealthChatFieldRequirement.Required, + keyboardType = KeyboardType.Text, + ), + LastName( + apiKey = "lastName", + labelRes = HealthChatStrings.healthchat_field_last_name, + placeholderRes = null, + maxLength = 100, + requirement = HealthChatFieldRequirement.Required, + keyboardType = KeyboardType.Text, + ), + Specialty( + apiKey = "specialty", + labelRes = HealthChatStrings.healthchat_field_specialty, + placeholderRes = HealthChatStrings.healthchat_field_specialty_placeholder, + maxLength = 200, + requirement = HealthChatFieldRequirement.Required, + keyboardType = KeyboardType.Text, + ), + PracticeName( + apiKey = "practiceName", + labelRes = HealthChatStrings.healthchat_field_practice_name, + placeholderRes = HealthChatStrings.healthchat_field_practice_name_placeholder, + maxLength = 200, + requirement = HealthChatFieldRequirement.Required, + keyboardType = KeyboardType.Text, + ), + BusinessCity( + apiKey = "businessCity", + labelRes = HealthChatStrings.healthchat_field_city, + placeholderRes = HealthChatStrings.healthchat_field_city_placeholder, + maxLength = 100, + requirement = HealthChatFieldRequirement.Required, + keyboardType = KeyboardType.Text, + ), + FurtherInfo( + apiKey = "furtherInfo", + labelRes = HealthChatStrings.healthchat_field_further_info, + placeholderRes = null, + maxLength = 1000, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Text, + minLines = 3, + ), + BusinessEmail( + apiKey = "businessEmail", + labelRes = HealthChatStrings.healthchat_field_business_email, + placeholderRes = HealthChatStrings.healthchat_field_business_email_placeholder, + maxLength = 200, + requirement = HealthChatFieldRequirement.RecommendedForCompleteness, + keyboardType = KeyboardType.Email, + ), + BusinessTel( + apiKey = "businessTel", + labelRes = HealthChatStrings.healthchat_field_business_tel, + placeholderRes = HealthChatStrings.healthchat_field_business_tel_placeholder, + maxLength = 50, + requirement = HealthChatFieldRequirement.RecommendedForCompleteness, + keyboardType = KeyboardType.Phone, + ), + Street( + apiKey = "street", + labelRes = HealthChatStrings.healthchat_field_street, + placeholderRes = null, + maxLength = 200, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Text, + ), + HouseNumber( + apiKey = "houseNumber", + labelRes = HealthChatStrings.healthchat_field_house_number, + placeholderRes = null, + maxLength = 20, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Text, + ), + PostalCode( + apiKey = "postalCode", + labelRes = HealthChatStrings.healthchat_field_postal_code, + placeholderRes = null, + maxLength = 20, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Text, + ), + Website( + apiKey = "website", + labelRes = HealthChatStrings.healthchat_field_website, + placeholderRes = HealthChatStrings.healthchat_field_website_placeholder, + maxLength = 500, + requirement = HealthChatFieldRequirement.Optional, + keyboardType = KeyboardType.Uri, + ), + ; + + companion object { + fun fromApiKey(apiKey: String): HealthChatProfileField? { + return entries.firstOrNull { it.apiKey.equals(apiKey, ignoreCase = true) } + } + } +} + +/** + * Form sections in display order (mirrors the web form). + */ +enum class HealthChatProfileFormSection( + @StringRes val titleRes: Int, + val fields: List, +) { + Personal( + titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_personal, + fields = listOf( + HealthChatProfileField.Title, + HealthChatProfileField.FirstName, + HealthChatProfileField.LastName, + ), + ), + Professional( + titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_professional, + fields = listOf( + HealthChatProfileField.Specialty, + HealthChatProfileField.PracticeName, + HealthChatProfileField.BusinessCity, + HealthChatProfileField.FurtherInfo, + ), + ), + Contact( + titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_contact, + fields = listOf( + HealthChatProfileField.BusinessEmail, + HealthChatProfileField.BusinessTel, + ), + ), + Address( + titleRes = HealthChatStrings.healthchat_screen_edit_profile_section_address, + fields = listOf( + HealthChatProfileField.Street, + HealthChatProfileField.HouseNumber, + HealthChatProfileField.PostalCode, + HealthChatProfileField.Website, + ), + ), +} + +/** The current server-side value of a field, or null when unset. */ +fun HealthChatMember.valueOf(field: HealthChatProfileField): String? { + return when (field) { + HealthChatProfileField.Title -> title + HealthChatProfileField.FirstName -> firstName + HealthChatProfileField.LastName -> lastName + HealthChatProfileField.Specialty -> specialty + HealthChatProfileField.PracticeName -> practiceName + HealthChatProfileField.BusinessCity -> businessCity + HealthChatProfileField.FurtherInfo -> furtherInfo + HealthChatProfileField.BusinessEmail -> businessEmail + HealthChatProfileField.BusinessTel -> businessTel + HealthChatProfileField.Street -> street + HealthChatProfileField.HouseNumber -> houseNumber + HealthChatProfileField.PostalCode -> postalCode + HealthChatProfileField.Website -> website + } +} + +/** + * Diff-only update: unchanged fields stay null (omitted from the PUT body), changed fields + * carry their trimmed new value, cleared fields carry "". + */ +fun buildProfileUpdate( + baseline: HealthChatMember, + values: Map, +): HealthChatProfileUpdate { + fun diff(field: HealthChatProfileField): String? { + val newValue = values[field].orEmpty().trim() + val oldValue = baseline.valueOf(field).orEmpty().trim() + return newValue.takeIf { it != oldValue } + } + return HealthChatProfileUpdate( + title = diff(HealthChatProfileField.Title), + firstName = diff(HealthChatProfileField.FirstName), + lastName = diff(HealthChatProfileField.LastName), + specialty = diff(HealthChatProfileField.Specialty), + practiceName = diff(HealthChatProfileField.PracticeName), + businessCity = diff(HealthChatProfileField.BusinessCity), + furtherInfo = diff(HealthChatProfileField.FurtherInfo), + businessEmail = diff(HealthChatProfileField.BusinessEmail), + businessTel = diff(HealthChatProfileField.BusinessTel), + street = diff(HealthChatProfileField.Street), + houseNumber = diff(HealthChatProfileField.HouseNumber), + postalCode = diff(HealthChatProfileField.PostalCode), + website = diff(HealthChatProfileField.Website), + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt new file mode 100644 index 0000000000..554b48eaa1 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidator.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile + +import androidx.annotation.StringRes +import io.element.android.features.healthchat.api.HealthChatStrings + +/** + * Client-side validation of the profile form. Pure Kotlin (no `android.util.Patterns`, + * which is null in JVM unit tests). The server stays authoritative. + */ +object HealthChatProfileValidator { + val EMAIL_REGEX = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]{2,}$""") + val URL_REGEX = Regex("""^https?://[^\s/$.?#].\S*$""", RegexOption.IGNORE_CASE) + + /** + * @return client-side errors per field; empty when the form can be saved. + * Email/tel are deliberately NOT save blockers (web parity — don't trap someone who + * only wants to fix a typo); only format checks and the 5 required fields block. + */ + fun validate(values: Map): Map { + val errors = mutableMapOf() + for (field in HealthChatProfileField.entries) { + val value = values[field].orEmpty().trim() + val error = validateField(field, value) + if (error != null) { + errors[field] = error + } + } + return errors + } + + @StringRes + private fun validateField(field: HealthChatProfileField, trimmedValue: String): Int? { + if (trimmedValue.isEmpty()) { + return if (field.requirement == HealthChatFieldRequirement.Required) { + HealthChatStrings.healthchat_error_field_required + } else { + null + } + } + return when (field) { + HealthChatProfileField.BusinessEmail -> + HealthChatStrings.healthchat_error_invalid_email.takeIf { !EMAIL_REGEX.matches(trimmedValue) } + HealthChatProfileField.Website -> + HealthChatStrings.healthchat_error_invalid_url.takeIf { !URL_REGEX.matches(trimmedValue) } + else -> null + } + } + + fun isValidEmail(value: String): Boolean = EMAIL_REGEX.matches(value.trim()) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt new file mode 100644 index 0000000000..97def05d50 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileEvent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.libraries.matrix.ui.media.AvatarAction + +sealed interface EditHealthChatProfileEvent { + data class UpdateField(val field: HealthChatProfileField, val value: String) : EditHealthChatProfileEvent + data class HandleAvatarAction(val action: AvatarAction) : EditHealthChatProfileEvent + data object Save : EditHealthChatProfileEvent + data object RetryLoad : EditHealthChatProfileEvent + data object Exit : EditHealthChatProfileEvent + data object CloseDialog : EditHealthChatProfileEvent +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt new file mode 100644 index 0000000000..b755298536 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNavigator.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +interface EditHealthChatProfileNavigator { + fun close() +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt new file mode 100644 index 0000000000..6b7c60998c --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.healthchat.api.HealthChatEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class EditHealthChatProfileNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EditHealthChatProfilePresenter.Factory, +) : Node(buildContext, plugins = plugins), EditHealthChatProfileNavigator { + private val callback: HealthChatEntryPoint.Callback = callback() + private val presenter = presenterFactory.create(navigator = this) + + override fun close() { + callback.onDone() + } + + @Composable + override fun View(modifier: Modifier) { + EditHealthChatProfileView( + state = presenter.present(), + onEditProfileSuccess = ::close, + modifier = modifier, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt new file mode 100644 index 0000000000..8f255114a6 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenter.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatMember +import io.element.android.features.healthchat.api.HealthChatProfileRepository +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.features.healthchat.impl.profile.HealthChatProfileValidator +import io.element.android.features.healthchat.impl.profile.buildProfileUpdate +import io.element.android.features.healthchat.impl.profile.valueOf +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsEvent +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException + +@AssistedInject +class EditHealthChatProfilePresenter( + @Assisted private val navigator: EditHealthChatProfileNavigator, + private val profileRepository: HealthChatProfileRepository, + private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, + private val temporaryUriDeleter: TemporaryUriDeleter, + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + + @AssistedFactory + interface Factory { + fun create(navigator: EditHealthChatProfileNavigator): EditHealthChatProfilePresenter + } + + @Composable + override fun present(): EditHealthChatProfileState { + val cameraPermissionState = cameraPermissionPresenter.present() + val coroutineScope = rememberCoroutineScope() + var loadTrigger by rememberSaveable { mutableIntStateOf(0) } + var loadState by remember { mutableStateOf>(AsyncData.Uninitialized) } + var baseline by remember { mutableStateOf(null) } + var values by remember { mutableStateOf(persistentMapOf()) } + var fieldErrors by remember { mutableStateOf(persistentMapOf()) } + var serverFieldErrors by remember { mutableStateOf(persistentMapOf()) } + var avatarUri by remember { mutableStateOf(null) } + var completenessMessage by remember { mutableStateOf(null) } + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + LaunchedEffect(loadTrigger) { + loadState = AsyncData.Loading() + profileRepository.refreshOwnProfile().fold( + onSuccess = { member -> + baseline = member + values = HealthChatProfileField.entries + .associateWith { field -> member.valueOf(field).orEmpty() } + .let { persistentMapOf().putAll(it) } + avatarUri = member.avatarUrl + loadState = AsyncData.Success(Unit) + }, + onFailure = { loadState = AsyncData.Failure(it) }, + ) + // Advisory only — a failure here must never block the editor. + profileRepository.refreshCompleteness().onSuccess { completeness -> + completenessMessage = completeness.message.takeIf { !completeness.isComplete } + } + } + + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> + if (uri != null) { + temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri()) + avatarUri = uri.toString() + } + } + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> + if (uri != null) { + temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri()) + avatarUri = uri.toString() + } + } + ) + + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + + val avatarActions by remember(avatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { avatarUri != null }, + ).toImmutableList() + } + } + + val hasChanges by remember { + derivedStateOf { + val currentBaseline = baseline + currentBaseline != null && + ( + !buildProfileUpdate(currentBaseline, values).isEmpty() || + avatarUri != currentBaseline.avatarUrl + ) + } + } + + fun handleEvent(event: EditHealthChatProfileEvent) { + when (event) { + is EditHealthChatProfileEvent.UpdateField -> { + values = values.put(event.field, event.value.take(event.field.maxLength)) + fieldErrors = fieldErrors.remove(event.field) + serverFieldErrors = serverFieldErrors.remove(event.field) + } + is EditHealthChatProfileEvent.HandleAvatarAction -> when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) + } + AvatarAction.Remove -> { + temporaryUriDeleter.delete(avatarUri.takeIf { it?.startsWith("content:") == true }?.toUri()) + avatarUri = null + } + } + EditHealthChatProfileEvent.Save -> { + val currentBaseline = baseline ?: return + val errors = HealthChatProfileValidator.validate(values) + if (errors.isNotEmpty()) { + fieldErrors = persistentMapOf().putAll(errors) + return + } + coroutineScope.saveChanges( + baseline = currentBaseline, + values = values, + avatarUri = avatarUri, + action = saveAction, + onValidationError = { validation -> + serverFieldErrors = persistentMapOf().putAll( + validation.fieldErrors.entries + .mapNotNull { (key, messages) -> + HealthChatProfileField.fromApiKey(key)?.let { field -> + field to messages.joinToString("\n") + } + } + .toMap() + ) + }, + ) + } + EditHealthChatProfileEvent.RetryLoad -> loadTrigger++ + EditHealthChatProfileEvent.Exit -> when (saveAction.value) { + is AsyncAction.Confirming -> { + saveAction.value = AsyncAction.Uninitialized + navigator.close() + } + AsyncAction.Loading -> Unit + is AsyncAction.Failure, + is AsyncAction.Success -> Unit + AsyncAction.Uninitialized -> { + if (hasChanges) { + saveAction.value = AsyncAction.ConfirmingCancellation + } else { + navigator.close() + } + } + } + EditHealthChatProfileEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + } + } + + return EditHealthChatProfileState( + userId = matrixClient.sessionId, + loadState = loadState, + values = values.toImmutableMap(), + fieldErrors = fieldErrors.toImmutableMap(), + serverFieldErrors = serverFieldErrors.toImmutableMap(), + displayName = baseline?.displayName, + avatarUrl = avatarUri, + avatarActions = avatarActions, + completenessMessage = completenessMessage, + saveButtonEnabled = hasChanges && saveAction.value !is AsyncAction.Loading, + saveAction = saveAction.value, + cameraPermissionState = cameraPermissionState, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.saveChanges( + baseline: HealthChatMember, + values: Map, + avatarUri: String?, + action: MutableState>, + onValidationError: (HealthChatApiException.Validation) -> Unit, + ) = launch { + action.value = AsyncAction.Loading + try { + // Fields first: the avatar is the likelier failure, and on avatar failure the + // dismissed error dialog re-loads the profile so the form shows reality. + val update = buildProfileUpdate(baseline, values) + if (!update.isEmpty()) { + profileRepository.updateOwnProfile(update).getOrThrow() + } + if (avatarUri != baseline.avatarUrl) { + if (avatarUri != null) { + uploadAvatar(avatarUri.toUri()).getOrThrow() + } else { + profileRepository.deleteAvatar().getOrThrow() + } + } + // Pull the server-derived display name into MatrixClient.userProfile. Never call + // setDisplayName() from the client — the server derives it and syncs Zitadel. + matrixClient.getUserProfile() + profileRepository.refreshCompleteness() + action.value = AsyncAction.Success(Unit) + } catch (exception: CancellationException) { + throw exception + } catch (exception: HealthChatApiException.Validation) { + onValidationError(exception) + action.value = AsyncAction.Uninitialized + } catch (exception: Exception) { + Timber.e(exception, "Failed to save the HealthChat profile") + action.value = AsyncAction.Failure(exception) + } + } + + private suspend fun uploadAvatar(avatarUri: Uri): Result { + val preprocessed = mediaPreProcessor.process( + uri = avatarUri, + mimeType = MimeTypes.Jpeg, + deleteOriginal = false, + // Deliberately NOT the user's optimization preference: a disabled optimization + // setting would push a full-size original into a 2 MiB endpoint. + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ).getOrThrow() + val bytes = preprocessed.file.readBytes() + if (bytes.size > MAX_AVATAR_SIZE_BYTES) { + throw HealthChatAvatarTooLargeException() + } + return profileRepository.uploadAvatar( + fileName = "avatar.jpg", + mimeType = MimeTypes.Jpeg, + data = bytes, + ) + } + + companion object { + // Server-enforced limit: 2 MB. + const val MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024 + } +} + +/** The processed avatar still exceeds the server's 2 MiB limit; fails before any network call. */ +class HealthChatAvatarTooLargeException : Exception("Avatar exceeds the 2 MiB limit") diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt new file mode 100644 index 0000000000..214ea6d1c9 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileState.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +data class EditHealthChatProfileState( + val userId: UserId, + /** + * The initial profile GET. A full-screen error with retry: without a loaded baseline the + * diff-only save cannot be computed (a blank form would clear server fields). + */ + val loadState: AsyncData, + val values: ImmutableMap, + /** Client-side validation errors (string resources). */ + val fieldErrors: ImmutableMap, + /** Verbatim server-side validation errors, matched by api key. */ + val serverFieldErrors: ImmutableMap, + val displayName: String?, + val avatarUrl: String?, + val avatarActions: ImmutableList, + /** Verbatim server advisory when the profile is incomplete; never blocks saving. */ + val completenessMessage: String?, + val saveButtonEnabled: Boolean, + val saveAction: AsyncAction, + val cameraPermissionState: PermissionsState, + val eventSink: (EditHealthChatProfileEvent) -> Unit, +) diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt new file mode 100644 index 0000000000..ebbeac1992 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileStateProvider.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap + +open class EditHealthChatProfileStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEditHealthChatProfileState(), + anEditHealthChatProfileState(loadState = AsyncData.Loading()), + anEditHealthChatProfileState( + loadState = AsyncData.Failure(HealthChatApiException.Network(RuntimeException("no network"))), + ), + anEditHealthChatProfileState( + fieldErrors = persistentMapOf( + HealthChatProfileField.FirstName to HealthChatStrings.healthchat_error_field_required, + HealthChatProfileField.Website to HealthChatStrings.healthchat_error_invalid_url, + ), + ), + anEditHealthChatProfileState( + serverFieldErrors = persistentMapOf( + HealthChatProfileField.BusinessEmail to "The Email field is not a valid e-mail address.", + ), + ), + anEditHealthChatProfileState( + completenessMessage = "Bitte vervollständigen Sie Ihr Profil, bevor Sie die Benutzersuche verwenden", + ), + anEditHealthChatProfileState(saveAction = AsyncAction.Loading), + ) +} + +fun anEditHealthChatProfileState( + userId: UserId = UserId("@erika.muster:healthchat.ch"), + loadState: AsyncData = AsyncData.Success(Unit), + values: ImmutableMap = aProfileFormValues(), + fieldErrors: ImmutableMap = persistentMapOf(), + serverFieldErrors: ImmutableMap = persistentMapOf(), + displayName: String? = "Dr. med. Erika Muster", + avatarUrl: String? = null, + avatarActions: ImmutableList = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto), + completenessMessage: String? = null, + saveButtonEnabled: Boolean = true, + saveAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + eventSink: (EditHealthChatProfileEvent) -> Unit = {}, +) = EditHealthChatProfileState( + userId = userId, + loadState = loadState, + values = values, + fieldErrors = fieldErrors, + serverFieldErrors = serverFieldErrors, + displayName = displayName, + avatarUrl = avatarUrl, + avatarActions = avatarActions, + completenessMessage = completenessMessage, + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction, + cameraPermissionState = cameraPermissionState, + eventSink = eventSink, +) + +fun aProfileFormValues(): ImmutableMap { + return mapOf( + HealthChatProfileField.Title to "Dr. med.", + HealthChatProfileField.FirstName to "Erika", + HealthChatProfileField.LastName to "Muster", + HealthChatProfileField.Specialty to "Allgemeinmedizin", + HealthChatProfileField.PracticeName to "Praxis Muster", + HealthChatProfileField.BusinessCity to "Zürich", + HealthChatProfileField.FurtherInfo to "", + HealthChatProfileField.BusinessEmail to "praxis@muster.ch", + HealthChatProfileField.BusinessTel to "+41 44 123 45 67", + HealthChatProfileField.Street to "Musterstrasse", + HealthChatProfileField.HouseNumber to "12", + HealthChatProfileField.PostalCode to "8001", + HealthChatProfileField.Website to "https://praxis-muster.ch", + ).toImmutableMap() +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt new file mode 100644 index 0000000000..df851bdd04 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfileView.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.impl.healthChatUserMessage +import io.element.android.features.healthchat.impl.profile.HealthChatFieldRequirement +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.features.healthchat.impl.profile.HealthChatProfileFormSection +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog +import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet +import io.element.android.libraries.matrix.ui.components.AvatarPickerState +import io.element.android.libraries.matrix.ui.components.AvatarPickerView +import io.element.android.libraries.permissions.api.PermissionsView +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditHealthChatProfileView( + state: EditHealthChatProfileState, + onEditProfileSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } + + fun onBackClick() { + focusManager.clearFocus() + state.eventSink(EditHealthChatProfileEvent.Exit) + } + + BackHandler(enabled = true, ::onBackClick) + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + titleStr = stringResource(HealthChatStrings.healthchat_screen_edit_profile_title), + navigationIcon = { BackButton(::onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.saveButtonEnabled, + onClick = { + focusManager.clearFocus() + state.eventSink(EditHealthChatProfileEvent.Save) + }, + ) + } + ) + }, + ) { padding -> + when (state.loadState) { + AsyncData.Uninitialized, + is AsyncData.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + is AsyncData.Failure -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Text( + text = stringResource(HealthChatStrings.healthchat_screen_edit_profile_load_error), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + ) + Button( + text = stringResource(CommonStrings.action_retry), + onClick = { state.eventSink(EditHealthChatProfileEvent.RetryLoad) }, + ) + } + } + is AsyncData.Success -> { + ProfileForm( + state = state, + onAvatarClick = { + focusManager.clearFocus() + isAvatarActionsSheetVisible.value = true + }, + modifier = Modifier.padding(padding), + ) + } + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(EditHealthChatProfileEvent.HandleAvatarAction(it)) } + ) + + AsyncActionView( + async = state.saveAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(HealthChatStrings.healthchat_screen_edit_profile_saving), + ) + }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + SaveChangesDialog( + onSaveClick = { state.eventSink(EditHealthChatProfileEvent.Save) }, + onDiscardClick = { state.eventSink(EditHealthChatProfileEvent.Exit) }, + onDismiss = { state.eventSink(EditHealthChatProfileEvent.CloseDialog) }, + ) + } + } + }, + onSuccess = { onEditProfileSuccess() }, + errorTitle = { stringResource(HealthChatStrings.healthchat_screen_edit_profile_error_title) }, + errorMessage = { error -> + when (error) { + is HealthChatAvatarTooLargeException -> stringResource(HealthChatStrings.healthchat_error_avatar_too_large) + else -> error.healthChatUserMessage() + } + }, + onErrorDismiss = { + state.eventSink(EditHealthChatProfileEvent.CloseDialog) + // Field changes may already be persisted when a later step failed: + // re-load so the form shows the server's reality. + state.eventSink(EditHealthChatProfileEvent.RetryLoad) + }, + ) + } + PermissionsView( + state = state.cameraPermissionState, + ) +} + +@Composable +private fun ProfileForm( + state: EditHealthChatProfileState, + onAvatarClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + val avatarPickerState = remember(state.avatarUrl) { + AvatarPickerState.Selected( + avatarData = AvatarData( + id = state.userId.value, + name = state.displayName, + size = AvatarSize.EditProfileDetails, + url = state.avatarUrl, + ), + type = AvatarType.User, + ) + } + AvatarPickerView( + state = avatarPickerState, + onClick = onAvatarClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = state.userId.value, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + ) + state.completenessMessage?.let { message -> + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textCriticalPrimary, + modifier = Modifier.fillMaxWidth(), + ) + } + for (section in HealthChatProfileFormSection.entries) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(section.titleRes), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + for (field in section.fields) { + Spacer(modifier = Modifier.height(12.dp)) + ProfileFormField(state = state, field = field) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun ProfileFormField( + state: EditHealthChatProfileState, + field: HealthChatProfileField, + modifier: Modifier = Modifier, +) { + val clientError = state.fieldErrors[field]?.let { stringResource(it) } + val serverError = state.serverFieldErrors[field] + val error = clientError ?: serverError + val supportingText = error + ?: if (field.requirement == HealthChatFieldRequirement.RecommendedForCompleteness) { + stringResource(HealthChatStrings.healthchat_screen_edit_profile_recommended) + } else { + null + } + TextField( + value = state.values[field].orEmpty(), + onValueChange = { state.eventSink(EditHealthChatProfileEvent.UpdateField(field, it)) }, + label = stringResource(field.labelRes), + placeholder = field.placeholderRes?.let { stringResource(it) }, + supportingText = supportingText, + validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None, + singleLine = field.minLines == 1, + minLines = field.minLines, + keyboardOptions = KeyboardOptions(keyboardType = field.keyboardType), + modifier = modifier.fillMaxWidth(), + ) +} + +@PreviewsDayNight +@Composable +internal fun EditHealthChatProfileViewPreview( + @PreviewParameter(EditHealthChatProfileStateProvider::class) state: EditHealthChatProfileState +) = ElementPreview { + EditHealthChatProfileView( + state = state, + onEditProfileSuccess = {}, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt new file mode 100644 index 0000000000..511e12227d --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/DefaultHealthChatAccountStatusRenderer.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.healthchat.api.HealthChatAccountStatusRenderer +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultHealthChatAccountStatusRenderer( + private val presenter: HealthChatAccountStatusPresenter, +) : HealthChatAccountStatusRenderer { + @Composable + override fun Render( + onCompleteProfileClick: () -> Unit, + includePendingApproval: Boolean, + modifier: Modifier, + ) { + HealthChatAccountStatusView( + state = presenter.present(), + onCompleteProfileClick = onCompleteProfileClick, + includePendingApproval = includePendingApproval, + modifier = modifier, + ) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt new file mode 100644 index 0000000000..1f2130d488 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.features.healthchat.api.HealthChatProfileRepository +import io.element.android.libraries.architecture.Presenter + +@Inject +class HealthChatAccountStatusPresenter( + private val profileRepository: HealthChatProfileRepository, +) : Presenter { + @Composable + override fun present(): HealthChatAccountStatusState { + LaunchedEffect(Unit) { + // Failures fall through to Unknown below — the banners must never show errors. + profileRepository.refreshOwnProfile() + profileRepository.refreshCompleteness() + } + val ownProfile by profileRepository.ownProfile.collectAsState() + val completeness by profileRepository.completeness.collectAsState() + + val status = remember(ownProfile, completeness) { + val profileData = ownProfile.dataOrNull() + val completenessData = completeness.dataOrNull() + when { + // PendingApproval wins: completing a profile does nothing for an unapproved account. + profileData?.isApproved == false -> HealthChatAccountStatus.PendingApproval(serverMessage = null) + completenessData?.isComplete == false -> HealthChatAccountStatus.IncompleteProfile( + missingFields = completenessData.missingFields, + ) + profileData != null && completenessData != null -> HealthChatAccountStatus.Ok + else -> HealthChatAccountStatus.Unknown + } + } + return HealthChatAccountStatusState(status = status) + } +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt new file mode 100644 index 0000000000..caa29fdcec --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import kotlinx.collections.immutable.ImmutableList + +data class HealthChatAccountStatusState( + val status: HealthChatAccountStatus, +) + +sealed interface HealthChatAccountStatus { + /** Nothing is known (e.g. the API is unreachable) — render nothing, never an error. */ + data object Unknown : HealthChatAccountStatus + + /** Account not approved yet. Takes precedence over [IncompleteProfile]. */ + data class PendingApproval(val serverMessage: String?) : HealthChatAccountStatus + + /** Approved but the server reports missing profile fields (German labels, verbatim). */ + data class IncompleteProfile(val missingFields: ImmutableList) : HealthChatAccountStatus + + data object Ok : HealthChatAccountStatus +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt new file mode 100644 index 0000000000..c98f5bd767 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.collections.immutable.persistentListOf + +open class HealthChatAccountStatusStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + HealthChatAccountStatusState(status = HealthChatAccountStatus.PendingApproval(serverMessage = null)), + HealthChatAccountStatusState( + status = HealthChatAccountStatus.IncompleteProfile( + missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma", "Email (geschäftlich)"), + ), + ), + HealthChatAccountStatusState(status = HealthChatAccountStatus.Ok), + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt new file mode 100644 index 0000000000..7723f645af --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusView.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Renders the account status banner, or nothing when there is nothing to show. + * Both banners are deliberately NOT dismissible: they resolve server-side. + */ +@Composable +fun HealthChatAccountStatusView( + state: HealthChatAccountStatusState, + onCompleteProfileClick: () -> Unit, + includePendingApproval: Boolean, + modifier: Modifier = Modifier, +) { + when (val status = state.status) { + HealthChatAccountStatus.Unknown, + HealthChatAccountStatus.Ok -> Unit + is HealthChatAccountStatus.PendingApproval -> { + if (includePendingApproval) { + Announcement( + title = stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_title), + description = status.serverMessage + ?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message), + type = AnnouncementType.Informative(), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + is HealthChatAccountStatus.IncompleteProfile -> { + val localizedFields = status.missingFields.map { serverLabel -> + MissingFieldLabelMapper.mapLabel(serverLabel)?.let { stringResource(it) } ?: serverLabel + } + Announcement( + title = stringResource(HealthChatStrings.healthchat_banner_incomplete_title), + description = stringResource( + HealthChatStrings.healthchat_banner_incomplete_description, + localizedFields.joinToString(", "), + ), + type = AnnouncementType.Actionable( + actionText = stringResource(HealthChatStrings.healthchat_banner_incomplete_action), + onActionClick = onCompleteProfileClick, + onDismissClick = null, + ), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun HealthChatAccountStatusViewPreview( + @PreviewParameter(HealthChatAccountStatusStateProvider::class) state: HealthChatAccountStatusState +) = ElementPreview { + HealthChatAccountStatusView( + state = state, + onCompleteProfileClick = {}, + includePendingApproval = true, + ) +} diff --git a/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt new file mode 100644 index 0000000000..dfd2207eb4 --- /dev/null +++ b/features/healthchat/impl/src/main/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapper.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import androidx.annotation.StringRes +import io.element.android.features.healthchat.api.HealthChatStrings + +/** + * Maps the German labels the completeness endpoint returns (e.g. "Praxisname / Firma") to + * localized field strings. Unknown labels pass through verbatim — a backend copy-edit must + * degrade to German, never to a blank entry. + */ +object MissingFieldLabelMapper { + @StringRes + fun mapLabel(serverLabel: String): Int? { + return when (normalize(serverLabel)) { + "anzeigename" -> HealthChatStrings.healthchat_field_name + "fachgebiet" -> HealthChatStrings.healthchat_field_specialty + "praxisname / firma", "praxis / unternehmen" -> HealthChatStrings.healthchat_field_practice_name + "ort" -> HealthChatStrings.healthchat_field_city + "email (geschäftlich)", "e-mail (geschäftlich)" -> HealthChatStrings.healthchat_field_business_email + "telefon (geschäftlich)" -> HealthChatStrings.healthchat_field_business_tel + else -> null + } + } + + private fun normalize(label: String): String { + return label.trim().lowercase().replace(Regex("\\s+"), " ") + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt new file mode 100644 index 0000000000..1edb6e8d89 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/invite/HealthChatInvitePresenterTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.invite + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatInviteService +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.test.FakeHealthChatInviteService +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class HealthChatInvitePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - canSubmit requires an email and a long enough note`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canSubmit).isFalse() + initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch")) + initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("kurz")) + val shortNoteState = consumeItemsUntilPredicate { it.personalNote == "kurz" }.last() + assertThat(shortNoteState.canSubmit).isFalse() + shortNoteState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!")) + val readyState = consumeItemsUntilPredicate { it.canSubmit }.last() + assertThat(readyState.canSubmit).isTrue() + } + } + + @Test + fun `present - an invalid email blocks sending without an api call`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HealthChatInviteEvent.UpdateEmail("keine-email")) + initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!")) + val readyState = consumeItemsUntilPredicate { it.canSubmit }.last() + readyState.eventSink(HealthChatInviteEvent.Send) + val state = consumeItemsUntilPredicate { it.emailError != null }.last() + assertThat(state.emailError) + .isEqualTo(HealthChatFieldError.Localised(HealthChatStrings.healthchat_error_invalid_email)) + assertThat(state.sendAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - a successful send exposes the verbatim server message`() = runTest { + val inviteLambda = lambdaRecorder> { _, _ -> + Result.success("Einladung wurde erfolgreich versendet") + } + val presenter = createPresenter( + inviteService = FakeHealthChatInviteService(inviteLambda = { email, note -> inviteLambda(email, note) }), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch")) + initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote(" Willkommen bei HealthChat! ")) + val readyState = consumeItemsUntilPredicate { it.canSubmit }.last() + readyState.eventSink(HealthChatInviteEvent.Send) + val state = consumeItemsUntilPredicate { it.sendAction is AsyncAction.Success }.last() + assertThat((state.sendAction as AsyncAction.Success).data) + .isEqualTo("Einladung wurde erfolgreich versendet") + // Trimmed values are sent. + inviteLambda.assertions().isCalledOnce().with( + value("neu@beispiel.ch"), + value("Willkommen bei HealthChat!"), + ) + } + } + + @Test + fun `present - server validation errors land under their fields verbatim`() = runTest { + val presenter = createPresenter( + inviteService = FakeHealthChatInviteService( + inviteLambda = { _, _ -> + Result.failure( + HealthChatApiException.Validation( + status = 400, + title = "One or more validation errors occurred.", + fieldErrors = persistentMapOf( + "personalnote" to persistentListOf("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein"), + ), + ) + ) + }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch")) + initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!")) + val readyState = consumeItemsUntilPredicate { it.canSubmit }.last() + readyState.eventSink(HealthChatInviteEvent.Send) + val state = consumeItemsUntilPredicate { it.personalNoteError != null }.last() + assertThat(state.personalNoteError) + .isEqualTo(HealthChatFieldError.Server("Die persönliche Nachricht muss mindestens 10 Zeichen lang sein")) + assertThat(state.sendAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - other failures surface as an error dialog with the verbatim message`() = runTest { + val error = HealthChatApiException.NotApproved("Ihr Konto wird derzeit geprüft.") + val presenter = createPresenter( + inviteService = FakeHealthChatInviteService(inviteLambda = { _, _ -> Result.failure(error) }), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(HealthChatInviteEvent.UpdateEmail("neu@beispiel.ch")) + initialState.eventSink(HealthChatInviteEvent.UpdatePersonalNote("Willkommen bei HealthChat!")) + val readyState = consumeItemsUntilPredicate { it.canSubmit }.last() + readyState.eventSink(HealthChatInviteEvent.Send) + val state = consumeItemsUntilPredicate { it.sendAction is AsyncAction.Failure }.last() + assertThat((state.sendAction as AsyncAction.Failure).error).isEqualTo(error) + state.eventSink(HealthChatInviteEvent.CloseDialog) + consumeItemsUntilPredicate { it.sendAction is AsyncAction.Uninitialized } + } + } + + private fun createPresenter( + inviteService: HealthChatInviteService = FakeHealthChatInviteService(), + ) = HealthChatInvitePresenter(inviteService = inviteService) +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt new file mode 100644 index 0000000000..4d4300d9f7 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/HealthChatProfileValidatorTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.test.aHealthChatMember +import org.junit.Test + +class HealthChatProfileValidatorTest { + private val validValues = HealthChatProfileField.entries.associateWith { field -> + when (field) { + HealthChatProfileField.FirstName -> "Erika" + HealthChatProfileField.LastName -> "Muster" + HealthChatProfileField.Specialty -> "Allgemeinmedizin" + HealthChatProfileField.PracticeName -> "Praxis Muster" + HealthChatProfileField.BusinessCity -> "Zürich" + HealthChatProfileField.BusinessEmail -> "praxis@muster.ch" + HealthChatProfileField.Website -> "https://praxis-muster.ch" + else -> "" + } + } + + @Test + fun `a valid form has no errors`() { + assertThat(HealthChatProfileValidator.validate(validValues)).isEmpty() + } + + @Test + fun `each required field blocks when blank`() { + val requiredFields = listOf( + HealthChatProfileField.FirstName, + HealthChatProfileField.LastName, + HealthChatProfileField.Specialty, + HealthChatProfileField.PracticeName, + HealthChatProfileField.BusinessCity, + ) + for (field in requiredFields) { + val errors = HealthChatProfileValidator.validate(validValues + (field to " ")) + assertThat(errors).containsEntry(field, HealthChatStrings.healthchat_error_field_required) + } + } + + @Test + fun `recommended and optional fields never block when blank`() { + val values = validValues + + (HealthChatProfileField.BusinessEmail to "") + + (HealthChatProfileField.BusinessTel to "") + + (HealthChatProfileField.Website to "") + assertThat(HealthChatProfileValidator.validate(values)).isEmpty() + } + + @Test + fun `an invalid email is rejected`() { + val errors = HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.BusinessEmail to "keine-email")) + assertThat(errors).containsEntry(HealthChatProfileField.BusinessEmail, HealthChatStrings.healthchat_error_invalid_email) + } + + @Test + fun `an invalid url is rejected and a scheme is required`() { + val errors = HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.Website to "praxis-muster.ch")) + assertThat(errors).containsEntry(HealthChatProfileField.Website, HealthChatStrings.healthchat_error_invalid_url) + assertThat( + HealthChatProfileValidator.validate(validValues + (HealthChatProfileField.Website to "http://praxis-muster.ch")) + ).isEmpty() + } + + @Test + fun `buildProfileUpdate only contains the changed fields`() { + val baseline = aHealthChatMember() + val values = HealthChatProfileField.entries.associateWith { field -> + baseline.valueOf(field).orEmpty() + } + (HealthChatProfileField.Specialty to "Kardiologie") + val update = buildProfileUpdate(baseline, values) + assertThat(update.specialty).isEqualTo("Kardiologie") + // Exactly one field set — the other 12 stay null (omitted from the PUT). + assertThat(update.copy(specialty = null).isEmpty()).isTrue() + } + + @Test + fun `buildProfileUpdate clears a field with an empty string`() { + val baseline = aHealthChatMember() + val values = HealthChatProfileField.entries.associateWith { field -> + baseline.valueOf(field).orEmpty() + } + (HealthChatProfileField.Website to "") + val update = buildProfileUpdate(baseline, values) + assertThat(update.website).isEqualTo("") + assertThat(update.copy(website = null).isEmpty()).isTrue() + } + + @Test + fun `buildProfileUpdate ignores whitespace only changes`() { + val baseline = aHealthChatMember() + val values = HealthChatProfileField.entries.associateWith { field -> + baseline.valueOf(field).orEmpty() + } + (HealthChatProfileField.Specialty to " ${baseline.specialty} ") + assertThat(buildProfileUpdate(baseline, values).isEmpty()).isTrue() + } +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt new file mode 100644 index 0000000000..6fc2611a0d --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/profile/edit/EditHealthChatProfilePresenterTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.profile.edit + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatProfileRepository +import io.element.android.features.healthchat.api.HealthChatProfileUpdate +import io.element.android.features.healthchat.api.HealthChatStrings +import io.element.android.features.healthchat.impl.profile.HealthChatProfileField +import io.element.android.features.healthchat.test.FakeHealthChatProfileRepository +import io.element.android.features.healthchat.test.aHealthChatCompleteness +import io.element.android.features.healthchat.test.aHealthChatMember +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +class EditHealthChatProfilePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - loads the profile into the form values`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + assertThat(state.values[HealthChatProfileField.FirstName]).isEqualTo("Erika") + assertThat(state.values[HealthChatProfileField.Specialty]).isEqualTo("Allgemeinmedizin") + assertThat(state.avatarUrl).isNull() + // Nothing changed yet: saving is disabled. + assertThat(state.saveButtonEnabled).isFalse() + } + } + + @Test + fun `present - a failed load exposes a retryable failure`() = runTest { + var callCount = 0 + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { + callCount++ + if (callCount == 1) { + Result.failure(HealthChatApiException.Network(IOException("no network"))) + } else { + Result.success(aHealthChatMember()) + } + }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + ), + ) + presenter.test { + val failedState = consumeItemsUntilPredicate { it.loadState is AsyncData.Failure }.last() + failedState.eventSink(EditHealthChatProfileEvent.RetryLoad) + val state = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + assertThat(state.values[HealthChatProfileField.FirstName]).isEqualTo("Erika") + } + } + + @Test + fun `present - save sends only the changed field and refreshes the matrix profile`() = runTest { + val updateLambda = lambdaRecorder> { + Result.success("Profil erfolgreich aktualisiert") + } + val matrixClient = FakeMatrixClient() + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + updateOwnProfileLambda = { updateLambda(it) }, + ), + matrixClient = matrixClient, + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Specialty, "Kardiologie")) + val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last() + editedState.eventSink(EditHealthChatProfileEvent.Save) + consumeItemsUntilPredicate { it.saveAction is AsyncAction.Success } + // THE diff assert: exactly one field set, twelve nulls. + updateLambda.assertions().isCalledOnce().with( + value(HealthChatProfileUpdate(specialty = "Kardiologie")) + ) + } + } + + @Test + fun `present - clearing a field sends an empty string`() = runTest { + val updateLambda = lambdaRecorder> { + Result.success("Profil erfolgreich aktualisiert") + } + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + updateOwnProfileLambda = { updateLambda(it) }, + ), + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Website, "")) + val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last() + editedState.eventSink(EditHealthChatProfileEvent.Save) + consumeItemsUntilPredicate { it.saveAction is AsyncAction.Success } + updateLambda.assertions().isCalledOnce().with( + value(HealthChatProfileUpdate(website = "")) + ) + } + } + + @Test + fun `present - a required field left blank blocks the save without any api call`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + // No updateOwnProfileLambda: any call would lambdaError(). + ), + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.FirstName, "")) + val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last() + editedState.eventSink(EditHealthChatProfileEvent.Save) + val state = consumeItemsUntilPredicate { it.fieldErrors.isNotEmpty() }.last() + assertThat(state.fieldErrors[HealthChatProfileField.FirstName]) + .isEqualTo(HealthChatStrings.healthchat_error_field_required) + assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - server validation errors land under their fields without an error dialog`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + updateOwnProfileLambda = { + Result.failure( + HealthChatApiException.Validation( + status = 400, + title = "One or more validation errors occurred.", + fieldErrors = persistentMapOf( + "businessemail" to persistentListOf("The Email field is not a valid e-mail address."), + ), + ) + ) + }, + ), + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.BusinessEmail, "neu@muster.ch")) + val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last() + editedState.eventSink(EditHealthChatProfileEvent.Save) + val state = consumeItemsUntilPredicate { it.serverFieldErrors.isNotEmpty() }.last() + assertThat(state.serverFieldErrors[HealthChatProfileField.BusinessEmail]) + .isEqualTo("The Email field is not a valid e-mail address.") + assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - exiting with unsaved changes asks for confirmation`() = runTest { + val closeLambda = lambdaRecorder { } + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + ), + navigator = object : EditHealthChatProfileNavigator { + override fun close() { + closeLambda() + } + }, + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.UpdateField(HealthChatProfileField.Specialty, "Kardiologie")) + val editedState = consumeItemsUntilPredicate { it.saveButtonEnabled }.last() + editedState.eventSink(EditHealthChatProfileEvent.Exit) + val confirming = consumeItemsUntilPredicate { it.saveAction is AsyncAction.ConfirmingCancellation }.last() + closeLambda.assertions().isNeverCalled() + confirming.eventSink(EditHealthChatProfileEvent.Exit) + consumeItemsUntilPredicate { it.saveAction is AsyncAction.Uninitialized } + closeLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - exiting without changes closes directly`() = runTest { + val closeLambda = lambdaRecorder { } + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember()) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness()) }, + ), + navigator = object : EditHealthChatProfileNavigator { + override fun close() { + closeLambda() + } + }, + ) + presenter.test { + val loaded = consumeItemsUntilPredicate { it.loadState is AsyncData.Success }.last() + loaded.eventSink(EditHealthChatProfileEvent.Exit) + closeLambda.assertions().isCalledOnce() + } + } + + private fun createPresenter( + profileRepository: HealthChatProfileRepository = FakeHealthChatProfileRepository(), + matrixClient: MatrixClient = FakeMatrixClient(), + navigator: EditHealthChatProfileNavigator = object : EditHealthChatProfileNavigator { + override fun close() = lambdaError() + }, + ) = EditHealthChatProfilePresenter( + navigator = navigator, + profileRepository = profileRepository, + matrixClient = matrixClient, + mediaPickerProvider = FakePickerProvider(), + mediaPreProcessor = FakeMediaPreProcessor(), + temporaryUriDeleter = FakeTemporaryUriDeleter(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()), + ) +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt new file mode 100644 index 0000000000..1251760e8c --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/HealthChatAccountStatusPresenterTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.HealthChatApiException +import io.element.android.features.healthchat.api.HealthChatProfileRepository +import io.element.android.features.healthchat.test.FakeHealthChatProfileRepository +import io.element.android.features.healthchat.test.aHealthChatCompleteness +import io.element.android.features.healthchat.test.aHealthChatMember +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +class HealthChatAccountStatusPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - a complete approved profile is Ok`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = true)) }, + refreshCompletenessLambda = { Result.success(aHealthChatCompleteness(isComplete = true)) }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.status == HealthChatAccountStatus.Ok }.last() + assertThat(state.status).isEqualTo(HealthChatAccountStatus.Ok) + } + } + + @Test + fun `present - pending approval wins over an incomplete profile`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = false)) }, + refreshCompletenessLambda = { + Result.success( + aHealthChatCompleteness( + isComplete = false, + missingFields = persistentListOf("Fachgebiet"), + ) + ) + }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.status is HealthChatAccountStatus.PendingApproval }.last() + assertThat(state.status).isEqualTo(HealthChatAccountStatus.PendingApproval(serverMessage = null)) + } + } + + @Test + fun `present - an approved incomplete profile exposes the missing fields`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.success(aHealthChatMember(isApproved = true)) }, + refreshCompletenessLambda = { + Result.success( + aHealthChatCompleteness( + isComplete = false, + missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma"), + ) + ) + }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.status is HealthChatAccountStatus.IncompleteProfile }.last() + val status = state.status as HealthChatAccountStatus.IncompleteProfile + assertThat(status.missingFields).containsExactly("Fachgebiet", "Praxisname / Firma").inOrder() + } + } + + @Test + fun `present - failures degrade to Unknown and never to an error`() = runTest { + val presenter = createPresenter( + profileRepository = FakeHealthChatProfileRepository( + refreshOwnProfileLambda = { Result.failure(HealthChatApiException.Network(IOException("no network"))) }, + refreshCompletenessLambda = { Result.failure(HealthChatApiException.Network(IOException("no network"))) }, + ), + ) + presenter.test { + // Every emission stays Unknown — never an error state. + val states = consumeItemsUntilTimeout() + assertThat(states.map { it.status }.distinct()).containsExactly(HealthChatAccountStatus.Unknown) + } + } + + private fun createPresenter( + profileRepository: HealthChatProfileRepository = FakeHealthChatProfileRepository(), + ) = HealthChatAccountStatusPresenter(profileRepository = profileRepository) +} diff --git a/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt new file mode 100644 index 0000000000..efbbd076e2 --- /dev/null +++ b/features/healthchat/impl/src/test/kotlin/io/element/android/features/healthchat/impl/status/MissingFieldLabelMapperTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Novaloop AG + * Copyright (c) 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. + */ + +package io.element.android.features.healthchat.impl.status + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.healthchat.api.HealthChatStrings +import org.junit.Test + +class MissingFieldLabelMapperTest { + @Test + fun `the known server labels map to localized field strings`() { + assertThat(MissingFieldLabelMapper.mapLabel("Anzeigename")).isEqualTo(HealthChatStrings.healthchat_field_name) + assertThat(MissingFieldLabelMapper.mapLabel("Fachgebiet")).isEqualTo(HealthChatStrings.healthchat_field_specialty) + assertThat(MissingFieldLabelMapper.mapLabel("Praxisname / Firma")).isEqualTo(HealthChatStrings.healthchat_field_practice_name) + assertThat(MissingFieldLabelMapper.mapLabel("Ort")).isEqualTo(HealthChatStrings.healthchat_field_city) + assertThat(MissingFieldLabelMapper.mapLabel("Email (geschäftlich)")).isEqualTo(HealthChatStrings.healthchat_field_business_email) + assertThat(MissingFieldLabelMapper.mapLabel("Telefon (geschäftlich)")).isEqualTo(HealthChatStrings.healthchat_field_business_tel) + } + + @Test + fun `matching is tolerant to case and whitespace`() { + assertThat(MissingFieldLabelMapper.mapLabel(" praxisname / firma ")).isEqualTo(HealthChatStrings.healthchat_field_practice_name) + } + + @Test + fun `an unknown label returns null so the caller renders it verbatim`() { + assertThat(MissingFieldLabelMapper.mapLabel("Ein neues Feld")).isNull() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 5336e08d70..e4bbd64365 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -34,6 +34,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode +import io.element.android.features.healthchat.api.HealthChatEntryPoint import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.impl.components.RoomListMenuAction @@ -90,8 +91,9 @@ class HomeFlowNode( private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val leaveRoomRenderer: LeaveRoomRenderer, - // HealthChat fork: renders the member directory tab. + // HealthChat fork: renders the member directory tab and hosts the editor/invite flow. private val memberDirectoryRenderer: MemberDirectoryRenderer, + private val healthChatEntryPoint: HealthChatEntryPoint, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : BaseFlowNode( backstack = BackStack( @@ -143,6 +145,10 @@ class HomeFlowNode( @Parcelize data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget + + // HealthChat fork: profile editor and member invitation. + @Parcelize + data class HealthChat(val target: HealthChatEntryPoint.InitialTarget) : NavTarget } private fun navigateToReportRoom(roomId: RoomId) { @@ -262,9 +268,12 @@ class HomeFlowNode( onOpenDm = ::navigateToRoom, onMemberClick = { callback.navigateToUserProfile(it) }, onMyProfileClick = { callback.navigateToUserProfile(matrixClient.sessionId) }, - // Hidden until the native editor and invite screens exist. - onEditProfileClick = null, - onInviteClick = null, + onEditProfileClick = { + backstack.push(NavTarget.HealthChat(HealthChatEntryPoint.InitialTarget.EditOwnProfile)) + }, + onInviteClick = { + backstack.push(NavTarget.HealthChat(HealthChatEntryPoint.InitialTarget.Invite)) + }, modifier = Modifier, ) }, @@ -313,6 +322,18 @@ class HomeFlowNode( listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving, ) } + is NavTarget.HealthChat -> { + healthChatEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = HealthChatEntryPoint.Params(navTarget.target), + callback = object : HealthChatEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + }, + ) + } NavTarget.Root -> rootNode(buildContext) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index d58bd7acb9..028ea42421 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -41,6 +41,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() }, + healthChatEntryPoint = { _, _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, ) } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 1c801dbbad..8d589d6855 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiUtils) implementation(projects.libraries.fullscreenintent.api) + implementation(projects.features.healthchat.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 15718ca0f0..1d1df82992 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -21,6 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint +import io.element.android.features.healthchat.api.HealthChatEntryPoint import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.logout.api.LogoutEntryPoint @@ -34,7 +35,6 @@ import io.element.android.features.preferences.impl.labs.LabsNode import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode import io.element.android.features.preferences.impl.root.PreferencesRootNode -import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.canPop @@ -59,6 +59,8 @@ class PreferencesFlowNode( private val logoutEntryPoint: LogoutEntryPoint, private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint, private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint, + // HealthChat fork: the native 13-field profile editor. + private val healthChatEntryPoint: HealthChatEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -283,13 +285,18 @@ class PreferencesFlowNode( createNode(buildContext) } is NavTarget.UserProfile -> { - val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) - val callback = object : EditUserProfileNode.Callback { - override fun onDone() { - backstack.pop() - } - } - createNode(buildContext, listOf(inputs, callback)) + // HealthChat fork: the native 13-field editor replaces the upstream + // display-name editor (the server derives the display name). + healthChatEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = HealthChatEntryPoint.Params(HealthChatEntryPoint.InitialTarget.EditOwnProfile), + callback = object : HealthChatEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + }, + ) } NavTarget.LockScreenSettings -> { lockScreenEntryPoint.createNode( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 6b54a763af..eecf1ddefe 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -19,6 +19,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.compound.theme.ElementTheme +import io.element.android.features.healthchat.api.HealthChatAccountStatusRenderer import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab @@ -33,6 +34,8 @@ class PreferencesRootNode( @Assisted plugins: List, private val presenter: PreferencesRootPresenter, private val directLogoutView: DirectLogoutView, + // HealthChat fork: renders the account status banners. + private val healthChatAccountStatusRenderer: HealthChatAccountStatusRenderer, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun navigateToAddAccount() @@ -98,7 +101,14 @@ class PreferencesRootNode( callback.startSignOutFlow() } }, - onDeactivateClick = callback::startAccountDeactivationFlow + onDeactivateClick = callback::startAccountDeactivationFlow, + healthChatAccountStatus = { + healthChatAccountStatusRenderer.Render( + onCompleteProfileClick = { callback.navigateToUserProfile(state.myUser) }, + includePendingApproval = true, + modifier = Modifier, + ) + }, ) directLogoutView.Render(state = state.directLogoutState) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 4afc2d27bf..345f99c842 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -64,6 +64,8 @@ fun PreferencesRootView( onSignOutClick: () -> Unit, onDeactivateClick: () -> Unit, modifier: Modifier = Modifier, + // HealthChat fork: account status banners, rendered by features/healthchat. + healthChatAccountStatus: @Composable () -> Unit = {}, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -89,6 +91,7 @@ fun PreferencesRootView( HorizontalDivider() } // User status will be added here + healthChatAccountStatus() // 'Account' section ManageAccountSection( state = state, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index 963d7846b4..0050091b72 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -46,6 +46,7 @@ class DefaultPreferencesEntryPointTest { logoutEntryPoint = FakeLogoutEntryPoint(), openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(), accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(), + healthChatEntryPoint = { _, _, _, _ -> lambdaError() }, ) } val callback = object : PreferencesEntryPoint.Callback { diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png new file mode 100644 index 0000000000..a6d0ddbacc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:618c3386712d1429f79f58d7a178d8f2680553e2f31ccc70c6a2e07f3a00ceb3 +size 43138 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png new file mode 100644 index 0000000000..9ad4b2d15c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f80acea887449bd6dddffe5dc4673afdb882135236872773299a2e1cc2e58ed +size 44492 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png new file mode 100644 index 0000000000..78cf35595d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26eca6af4689aeb8ad36eec3de5922c9cb69232ca5b162eead72704aa82f00ab +size 44805 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png new file mode 100644 index 0000000000..359bb8748c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d028131b40f2565a59663c819a78f6f4f235f32d2fde673f563396a2b9f8e7e +size 49553 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png new file mode 100644 index 0000000000..bdfd959e0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ec06536ca66c5f6f99822c2cd300f838537d0a0f3f78aecc917f9b038754935 +size 37752 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png new file mode 100644 index 0000000000..875e27a66f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a60fc8e6eb794747de645ab472cfe670218be901f99b46fd177b661c9091560 +size 41612 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png new file mode 100644 index 0000000000..f7d6d2632a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e337e0e0aee9ac8cd2fc40e54be2ed4aac148dfa27c0f2464741e77427a8b205 +size 42963 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png new file mode 100644 index 0000000000..2ac906b2a0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fecfe2f115ac8a3c460ae9be8d1feb134763d2a8182d5d5707a3eecece118338 +size 43109 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png new file mode 100644 index 0000000000..53eb8645cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3662717c3921544837ec24c9bddba85b5388ad615f902059933a42b8000f9870 +size 47758 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png new file mode 100644 index 0000000000..e1be883abf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.invite_HealthChatInviteView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d230fc90d32b8484ba2cdb4b585b75dc2a658e81f1dfa5d9be9c16589ccb4d2c +size 36147 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png new file mode 100644 index 0000000000..4bfcb121a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9335aa7d97264db2f09aa38863eca1b7b41d5940422fffcfcf45fa35c0c4e035 +size 35708 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png new file mode 100644 index 0000000000..db12c4ce07 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5b8cedd4ce0eab31827babbd4d2fd6f3f23f9ee40fc26b89c6ea0da82981872 +size 9183 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png new file mode 100644 index 0000000000..54f3bf1866 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eca9bc03c1b36e3d082e4864e5959fc1db1cba27d24f9efb064c3c20dfa49980 +size 14751 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png new file mode 100644 index 0000000000..f127bf76e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f356fbe0bfad0c13a8b7e3debd54bdb737b37dc21279043527c6ea6c36698e81 +size 37316 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png new file mode 100644 index 0000000000..4bfcb121a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9335aa7d97264db2f09aa38863eca1b7b41d5940422fffcfcf45fa35c0c4e035 +size 35708 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png new file mode 100644 index 0000000000..177d6a7a5e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e5bd8c696ffe5370f836d6bb66a2009d6ca29128917f2b13b5fdbb291c52579 +size 42273 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png new file mode 100644 index 0000000000..61a2b61e58 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69397c4893fc4a507908f51a08ce3835bb73497b2bf1c73ef769859acfe95488 +size 35336 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png new file mode 100644 index 0000000000..173644ade3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9296c373659536ae15f2eefbb530a3900e501c3c98c78c2a31183471b815dbb7 +size 35078 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png new file mode 100644 index 0000000000..bb342e1d37 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8913fc1669b8d424fc3598c3b232659bfd731effb4f0da3c2335e6c8bed3412 +size 8943 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png new file mode 100644 index 0000000000..a26c5287e8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81c34429c5ef315a8208772aa4ca31067c7ca14eb51fc133a19a26f688bc9979 +size 14305 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png new file mode 100644 index 0000000000..ca804510fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fd97e6a9815559d582abb0316ad95ca0fa886eb3d45b8cd5fcd5c9328b10fcd +size 36758 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png new file mode 100644 index 0000000000..173644ade3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9296c373659536ae15f2eefbb530a3900e501c3c98c78c2a31183471b815dbb7 +size 35078 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png new file mode 100644 index 0000000000..b7c956e2f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce796b2da3b8363e26d980568800810ad47b51c75a1bfc28b2a53d0b20cb8996 +size 41499 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png new file mode 100644 index 0000000000..60ce9394a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.profile.edit_EditHealthChatProfileView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7efd221b39331f72f031c798675f0655d69a23c8f30446656c38401719d8c024 +size 34956 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png new file mode 100644 index 0000000000..9031d83945 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ff1b8dfc1a3e816c8633cc2c561ddfc30469115891d81da281fa55355e1769d +size 23368 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png new file mode 100644 index 0000000000..faf58324f9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b25a34776c04b5ef5d1bffcaeb08d0d3df49d598dbc3c24fa217c502e394433 +size 29231 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png new file mode 100644 index 0000000000..e0eb06581e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b7950eb492e517b9f320731ba8c89b1a6e5799db09d30afd29430544da4da7d +size 22782 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png new file mode 100644 index 0000000000..585422c9ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb24493aeb8ae5026f22a8cf68ea90d2a2e35221d592ebcd5e887abf922821ef +size 28461 diff --git a/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.healthchat.impl.status_HealthChatAccountStatusView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657