mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +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
|
||||
@@ -89,8 +90,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(
|
||||
@@ -142,6 +144,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) {
|
||||
@@ -264,9 +270,12 @@ class HomeFlowNode(
|
||||
onOpenDm = { navigateToRoom(roomId = it, eventId = null) },
|
||||
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,
|
||||
)
|
||||
},
|
||||
@@ -319,6 +328,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
@@ -43,6 +43,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
||||
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
leaveRoomRenderer = { _, _, _ -> lambdaError() },
|
||||
memberDirectoryRenderer = { _, _, _, _, _, _, _, _, _ -> lambdaError() },
|
||||
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,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
|
||||
@@ -36,7 +37,6 @@ import io.element.android.features.preferences.impl.moderation.ModerationAndSafe
|
||||
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
|
||||
@@ -65,6 +65,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(),
|
||||
@@ -341,13 +343,18 @@ class PreferencesFlowNode(
|
||||
createNode<MediaSettingsNode>(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.preferences.impl.account.PreferencesAccountCallback
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.callback
|
||||
@@ -33,6 +34,8 @@ class PreferencesRootNode(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PreferencesRootPresenter,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
// HealthChat fork: renders the account status banners.
|
||||
private val healthChatAccountStatusRenderer: HealthChatAccountStatusRenderer,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : PreferencesAccountCallback, Plugin {
|
||||
fun navigateToAddAccount()
|
||||
@@ -90,7 +93,14 @@ class PreferencesRootNode(
|
||||
onOpenNotificationSettings = callback::navigateToNotificationSettings,
|
||||
onEditProfileClick = callback::navigateToUserProfile,
|
||||
onSignOutClick = callback::startSignOutFlow,
|
||||
onDeactivateClick = callback::startAccountDeactivationFlow
|
||||
onDeactivateClick = callback::startAccountDeactivationFlow,
|
||||
healthChatAccountStatus = {
|
||||
healthChatAccountStatusRenderer.Render(
|
||||
onCompleteProfileClick = { callback.navigateToUserProfile(state.myUser) },
|
||||
includePendingApproval = true,
|
||||
modifier = Modifier,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -99,6 +99,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)
|
||||
|
||||
@@ -134,6 +136,8 @@ fun PreferencesRootView(
|
||||
thickness = 8.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
// HealthChat fork: account status banners head the account section.
|
||||
healthChatAccountStatus()
|
||||
// Account settings section
|
||||
PreferencesAccountView(
|
||||
state = state.preferencesAccountState,
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ class DefaultPreferencesEntryPointTest {
|
||||
logoutEntryPoint = FakeLogoutEntryPoint(),
|
||||
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
|
||||
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
|
||||
healthChatEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
)
|
||||
}
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user