mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Add profile editor, member invite and status banners (#4)
* feat(healthchat): add profile editor, member invite and status banners Native 13-field profile editor with diff-only saves and avatar upload through the HealthChat API (replaces the upstream display-name editor), email invitation for approved accounts, and non-dismissible account status banners in settings and the member directory. * fix(healthchat): satisfy detekt and konsist rules Konsist presenter helper shape, no '=' in state parameter docs, present-tense invite callback, and complete renderer KDoc.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+33
@@ -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,
|
||||
)
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
@@ -37,4 +37,46 @@
|
||||
<string name="healthchat_a11y_refresh">Aktualisieren</string>
|
||||
<string name="healthchat_a11y_edit_profile">Profil bearbeiten</string>
|
||||
<string name="healthchat_a11y_invite_member">Neues Mitglied einladen</string>
|
||||
<string name="healthchat_field_name">Anzeigename</string>
|
||||
<string name="healthchat_field_title">Titel</string>
|
||||
<string name="healthchat_field_title_placeholder">z.B. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">Vorname</string>
|
||||
<string name="healthchat_field_last_name">Nachname</string>
|
||||
<string name="healthchat_field_specialty_placeholder">z.B. Allgemeinmedizin</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">z.B. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Ort</string>
|
||||
<string name="healthchat_field_city_placeholder">z.B. Zürich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">praxis@beispiel.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<!-- Swiss German: "Strasse" (no ß) is correct for this product. -->
|
||||
<string name="healthchat_field_street" tools:ignore="Typos">Strasse</string>
|
||||
<string name="healthchat_field_house_number">Nr.</string>
|
||||
<string name="healthchat_field_postal_code">PLZ</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Profil bearbeiten</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Persönliche Angaben</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Berufliche Angaben</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Kontaktdaten</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Praxisadresse</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Profil wird gespeichert…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Profil konnte nicht gespeichert werden</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Das Profil konnte nicht geladen werden.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Für ein vollständiges Profil empfohlen</string>
|
||||
<string name="healthchat_error_field_required">Dieses Feld ist erforderlich</string>
|
||||
<string name="healthchat_error_invalid_email">Bitte eine gültige E-Mail-Adresse eingeben</string>
|
||||
<string name="healthchat_error_invalid_url">Bitte eine gültige URL eingeben (z.B. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Die persönliche Nachricht muss mindestens 10 Zeichen lang sein</string>
|
||||
<string name="healthchat_error_avatar_too_large">Das Bild darf höchstens 2 MB gross sein.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profil unvollständig</string>
|
||||
<string name="healthchat_banner_incomplete_description">Bitte vervollständigen Sie Ihr Profil, damit andere Mitglieder Sie finden können. Fehlende Angaben: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Profil vervollständigen</string>
|
||||
<string name="healthchat_screen_invite_title">Mitglied einladen</string>
|
||||
<string name="healthchat_screen_invite_description">Laden Sie neue Mitglieder zu HealthChat ein, indem Sie deren E-Mail-Adresse und eine persönliche Nachricht eingeben.</string>
|
||||
<string name="healthchat_screen_invite_email_label">E-Mail-Adresse</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">beispiel@domain.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Persönliche Nachricht</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Schreiben Sie eine persönliche Nachricht an das neue Mitglied…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 Zeichen</string>
|
||||
<string name="healthchat_screen_invite_send">Einladung senden</string>
|
||||
<string name="healthchat_screen_invite_sending">Einladung wird gesendet…</string>
|
||||
</resources>
|
||||
|
||||
@@ -38,4 +38,45 @@
|
||||
<string name="healthchat_a11y_refresh">Actualiser</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifier le profil</string>
|
||||
<string name="healthchat_a11y_invite_member">Inviter un nouveau membre</string>
|
||||
<string name="healthchat_field_name">Nom affiché</string>
|
||||
<string name="healthchat_field_title">Titre</string>
|
||||
<string name="healthchat_field_title_placeholder">p. ex. Dr méd.</string>
|
||||
<string name="healthchat_field_first_name">Prénom</string>
|
||||
<string name="healthchat_field_last_name">Nom</string>
|
||||
<string name="healthchat_field_specialty_placeholder">p. ex. médecine générale</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">p. ex. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Ville</string>
|
||||
<string name="healthchat_field_city_placeholder">p. ex. Zurich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">cabinet@exemple.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Rue</string>
|
||||
<string name="healthchat_field_house_number">N°</string>
|
||||
<string name="healthchat_field_postal_code">NPA</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Modifier le profil</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Informations personnelles</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Informations professionnelles</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Coordonnées</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Adresse du cabinet</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Enregistrement du profil…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Impossible d’enregistrer le profil</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Le profil n’a pas pu être chargé.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Recommandé pour un profil complet</string>
|
||||
<string name="healthchat_error_field_required">Ce champ est obligatoire</string>
|
||||
<string name="healthchat_error_invalid_email">Veuillez saisir une adresse e-mail valide</string>
|
||||
<string name="healthchat_error_invalid_url">Veuillez saisir une URL valide (p. ex. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Le message personnel doit contenir au moins 10 caractères</string>
|
||||
<string name="healthchat_error_avatar_too_large">L’image ne doit pas dépasser 2 Mo.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profil incomplet</string>
|
||||
<string name="healthchat_banner_incomplete_description">Veuillez compléter votre profil pour que les autres membres puissent vous trouver. Informations manquantes : %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Compléter le profil</string>
|
||||
<string name="healthchat_screen_invite_title">Inviter un membre</string>
|
||||
<string name="healthchat_screen_invite_description">Invitez de nouveaux membres sur HealthChat en saisissant leur adresse e-mail et un message personnel.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Adresse e-mail</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">exemple@domaine.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Message personnel</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Écrivez un message personnel au nouveau membre…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 caractères</string>
|
||||
<string name="healthchat_screen_invite_send">Envoyer l’invitation</string>
|
||||
<string name="healthchat_screen_invite_sending">Envoi de l’invitation…</string>
|
||||
</resources>
|
||||
|
||||
@@ -38,4 +38,45 @@
|
||||
<string name="healthchat_a11y_refresh">Aggiorna</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifica profilo</string>
|
||||
<string name="healthchat_a11y_invite_member">Invita un nuovo membro</string>
|
||||
<string name="healthchat_field_name">Nome visualizzato</string>
|
||||
<string name="healthchat_field_title">Titolo</string>
|
||||
<string name="healthchat_field_title_placeholder">ad es. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">Nome</string>
|
||||
<string name="healthchat_field_last_name">Cognome</string>
|
||||
<string name="healthchat_field_specialty_placeholder">ad es. medicina generale</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">ad es. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Città</string>
|
||||
<string name="healthchat_field_city_placeholder">ad es. Zurigo</string>
|
||||
<string name="healthchat_field_business_email_placeholder">studio@esempio.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Via</string>
|
||||
<string name="healthchat_field_house_number">N.</string>
|
||||
<string name="healthchat_field_postal_code">CAP</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Modifica profilo</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Dati personali</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Dati professionali</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Contatti</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Indirizzo dello studio</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Salvataggio del profilo…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Impossibile salvare il profilo</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Impossibile caricare il profilo.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Consigliato per un profilo completo</string>
|
||||
<string name="healthchat_error_field_required">Questo campo è obbligatorio</string>
|
||||
<string name="healthchat_error_invalid_email">Inserisci un indirizzo e-mail valido</string>
|
||||
<string name="healthchat_error_invalid_url">Inserisci un URL valido (ad es. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Il messaggio personale deve contenere almeno 10 caratteri</string>
|
||||
<string name="healthchat_error_avatar_too_large">L’immagine non deve superare i 2 MB.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profilo incompleto</string>
|
||||
<string name="healthchat_banner_incomplete_description">Completa il tuo profilo affinché gli altri membri possano trovarti. Dati mancanti: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Completa il profilo</string>
|
||||
<string name="healthchat_screen_invite_title">Invita un membro</string>
|
||||
<string name="healthchat_screen_invite_description">Invita nuovi membri su HealthChat inserendo il loro indirizzo e-mail e un messaggio personale.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Indirizzo e-mail</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">esempio@dominio.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Messaggio personale</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Scrivi un messaggio personale al nuovo membro…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 caratteri</string>
|
||||
<string name="healthchat_screen_invite_send">Invia invito</string>
|
||||
<string name="healthchat_screen_invite_sending">Invio dell’invito…</string>
|
||||
</resources>
|
||||
|
||||
@@ -38,4 +38,45 @@
|
||||
<string name="healthchat_a11y_refresh">Refresh</string>
|
||||
<string name="healthchat_a11y_edit_profile">Edit profile</string>
|
||||
<string name="healthchat_a11y_invite_member">Invite a new member</string>
|
||||
<string name="healthchat_field_name">Display name</string>
|
||||
<string name="healthchat_field_title">Title</string>
|
||||
<string name="healthchat_field_title_placeholder">e.g. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">First name</string>
|
||||
<string name="healthchat_field_last_name">Last name</string>
|
||||
<string name="healthchat_field_specialty_placeholder">e.g. general medicine</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">e.g. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">City</string>
|
||||
<string name="healthchat_field_city_placeholder">e.g. Zurich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">praxis@example.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Street</string>
|
||||
<string name="healthchat_field_house_number">No.</string>
|
||||
<string name="healthchat_field_postal_code">Postal code</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Edit profile</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Personal details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Professional details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Contact details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Practice address</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Saving profile…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Couldn’t save the profile</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">The profile couldn’t be loaded.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Recommended for a complete profile</string>
|
||||
<string name="healthchat_error_field_required">This field is required</string>
|
||||
<string name="healthchat_error_invalid_email">Please enter a valid email address</string>
|
||||
<string name="healthchat_error_invalid_url">Please enter a valid URL (e.g. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">The personal message must be at least 10 characters long</string>
|
||||
<string name="healthchat_error_avatar_too_large">The image must not exceed 2 MB.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profile incomplete</string>
|
||||
<string name="healthchat_banner_incomplete_description">Please complete your profile so other members can find you. Missing details: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Complete profile</string>
|
||||
<string name="healthchat_screen_invite_title">Invite a member</string>
|
||||
<string name="healthchat_screen_invite_description">Invite new members to HealthChat by entering their email address and a personal message.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Email address</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">example@domain.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Personal message</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Write a personal message to the new member…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 characters</string>
|
||||
<string name="healthchat_screen_invite_send">Send invitation</string>
|
||||
<string name="healthchat_screen_invite_sending">Sending invitation…</string>
|
||||
</resources>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
@@ -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<HealthChatFlowNode>(buildContext, plugins = listOf(params, callback))
|
||||
}
|
||||
}
|
||||
+75
@@ -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<Plugin>,
|
||||
) : BaseFlowNode<HealthChatFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins
|
||||
.filterIsInstance<HealthChatEntryPoint.Params>()
|
||||
.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<EditHealthChatProfileNode>(buildContext, plugins = plugins)
|
||||
NavTarget.Invite -> createNode<HealthChatInviteNode>(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
|
||||
}
|
||||
}
|
||||
+28
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
+41
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+113
@@ -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<HealthChatInviteState> {
|
||||
@Composable
|
||||
override fun present(): HealthChatInviteState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var personalNote by rememberSaveable { mutableStateOf("") }
|
||||
var emailError by remember { mutableStateOf<HealthChatFieldError?>(null) }
|
||||
var personalNoteError by remember { mutableStateOf<HealthChatFieldError?>(null) }
|
||||
val sendAction = remember { mutableStateOf<AsyncAction<String>>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -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<String>,
|
||||
val eventSink: (HealthChatInviteEvent) -> Unit,
|
||||
)
|
||||
+59
@@ -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<HealthChatInviteState> {
|
||||
override val values: Sequence<HealthChatInviteState>
|
||||
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<String> = AsyncAction.Uninitialized,
|
||||
eventSink: (HealthChatInviteEvent) -> Unit = {},
|
||||
) = HealthChatInviteState(
|
||||
email = email,
|
||||
personalNote = personalNote,
|
||||
emailError = emailError,
|
||||
personalNoteError = personalNoteError,
|
||||
canSubmit = canSubmit,
|
||||
sendAction = sendAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+146
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+242
@@ -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<HealthChatProfileField>,
|
||||
) {
|
||||
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<HealthChatProfileField, String>,
|
||||
): 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),
|
||||
)
|
||||
}
|
||||
+58
@@ -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<HealthChatProfileField, String>): Map<HealthChatProfileField, Int> {
|
||||
val errors = mutableMapOf<HealthChatProfileField, Int>()
|
||||
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())
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+13
@@ -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()
|
||||
}
|
||||
+45
@@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+302
@@ -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<EditHealthChatProfileState> {
|
||||
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<Unit>>(AsyncData.Uninitialized) }
|
||||
var baseline by remember { mutableStateOf<HealthChatMember?>(null) }
|
||||
var values by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, String>()) }
|
||||
var fieldErrors by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, Int>()) }
|
||||
var serverFieldErrors by remember { mutableStateOf(persistentMapOf<HealthChatProfileField, String>()) }
|
||||
var avatarUri by remember { mutableStateOf<String?>(null) }
|
||||
var completenessMessage by remember { mutableStateOf<String?>(null) }
|
||||
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(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<HealthChatProfileField, String>().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<HealthChatProfileField, Int>().putAll(errors)
|
||||
return
|
||||
}
|
||||
coroutineScope.saveChanges(
|
||||
baseline = currentBaseline,
|
||||
values = values,
|
||||
avatarUri = avatarUri,
|
||||
action = saveAction,
|
||||
onValidationError = { validation ->
|
||||
serverFieldErrors = persistentMapOf<HealthChatProfileField, String>().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<HealthChatProfileField, String>,
|
||||
avatarUri: String?,
|
||||
action: MutableState<AsyncAction<Unit>>,
|
||||
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<String> {
|
||||
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")
|
||||
+41
@@ -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<Unit>,
|
||||
val values: ImmutableMap<HealthChatProfileField, String>,
|
||||
/** Client-side validation errors (string resources). */
|
||||
val fieldErrors: ImmutableMap<HealthChatProfileField, Int>,
|
||||
/** Verbatim server-side validation errors, matched by api key. */
|
||||
val serverFieldErrors: ImmutableMap<HealthChatProfileField, String>,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?,
|
||||
val avatarActions: ImmutableList<AvatarAction>,
|
||||
/** Verbatim server advisory when the profile is incomplete; never blocks saving. */
|
||||
val completenessMessage: String?,
|
||||
val saveButtonEnabled: Boolean,
|
||||
val saveAction: AsyncAction<Unit>,
|
||||
val cameraPermissionState: PermissionsState,
|
||||
val eventSink: (EditHealthChatProfileEvent) -> Unit,
|
||||
)
|
||||
+99
@@ -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<EditHealthChatProfileState> {
|
||||
override val values: Sequence<EditHealthChatProfileState>
|
||||
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<Unit> = AsyncData.Success(Unit),
|
||||
values: ImmutableMap<HealthChatProfileField, String> = aProfileFormValues(),
|
||||
fieldErrors: ImmutableMap<HealthChatProfileField, Int> = persistentMapOf(),
|
||||
serverFieldErrors: ImmutableMap<HealthChatProfileField, String> = persistentMapOf(),
|
||||
displayName: String? = "Dr. med. Erika Muster",
|
||||
avatarUrl: String? = null,
|
||||
avatarActions: ImmutableList<AvatarAction> = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto),
|
||||
completenessMessage: String? = null,
|
||||
saveButtonEnabled: Boolean = true,
|
||||
saveAction: AsyncAction<Unit> = 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<HealthChatProfileField, String> {
|
||||
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()
|
||||
}
|
||||
+293
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+34
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+49
@@ -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<HealthChatAccountStatusState> {
|
||||
@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)
|
||||
}
|
||||
}
|
||||
+28
@@ -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<String>) : HealthChatAccountStatus
|
||||
|
||||
data object Ok : HealthChatAccountStatus
|
||||
}
|
||||
+25
@@ -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<HealthChatAccountStatusState> {
|
||||
override val values: Sequence<HealthChatAccountStatusState>
|
||||
get() = sequenceOf(
|
||||
HealthChatAccountStatusState(status = HealthChatAccountStatus.PendingApproval(serverMessage = null)),
|
||||
HealthChatAccountStatusState(
|
||||
status = HealthChatAccountStatus.IncompleteProfile(
|
||||
missingFields = persistentListOf("Fachgebiet", "Praxisname / Firma", "Email (geschäftlich)"),
|
||||
),
|
||||
),
|
||||
HealthChatAccountStatusState(status = HealthChatAccountStatus.Ok),
|
||||
)
|
||||
}
|
||||
+84
@@ -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,
|
||||
)
|
||||
}
|
||||
+36
@@ -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+"), " ")
|
||||
}
|
||||
}
|
||||
+141
@@ -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<String, String, Result<String>> { _, _ ->
|
||||
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)
|
||||
}
|
||||
+105
@@ -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()
|
||||
}
|
||||
}
|
||||
+254
@@ -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<HealthChatProfileUpdate, Result<String>> {
|
||||
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<HealthChatProfileUpdate, Result<String>> {
|
||||
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<Unit> { }
|
||||
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<Unit> { }
|
||||
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()),
|
||||
)
|
||||
}
|
||||
+106
@@ -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)
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -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<HomeFlowNode.NavTarget>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
||||
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
leaveRoomRenderer = { _, _, _ -> lambdaError() },
|
||||
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
|
||||
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+15
-8
@@ -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<PreferencesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
@@ -283,13 +285,18 @@ class PreferencesFlowNode(
|
||||
createNode<AdvancedSettingsNode>(buildContext)
|
||||
}
|
||||
is NavTarget.UserProfile -> {
|
||||
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
|
||||
val callback = object : EditUserProfileNode.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
createNode<EditUserProfileNode>(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(
|
||||
|
||||
+11
-1
@@ -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<Plugin>,
|
||||
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)
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ class DefaultPreferencesEntryPointTest {
|
||||
logoutEntryPoint = FakeLogoutEntryPoint(),
|
||||
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
|
||||
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
|
||||
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
)
|
||||
}
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
|
||||
Reference in New Issue
Block a user