37 Commits

Author SHA1 Message Date
joel.poyet a907501c08 chore(release): prepare 26.08.0 release notes
Fold the member directory, profile editor, invite and status banner features into the unreleased 26.08.0 changelog section.
2026-08-06 13:41:23 +02:00
joel.poyet 7f43998fa5 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.
2026-08-06 13:28:24 +02:00
joel.poyet edb3520c56 Add member directory tab to home navigation (#3)
* feat(healthchat): add member directory tab to home navigation

Third bottom-nav tab backed by the HealthChat API: searchable member
cards with native start-chat, approval-pending banner, retry on
failure and navigation into the native profile screens.

* fix(healthchat): stop tab back handler swallowing back presses

While a screen is pushed on top of Home, the still-composed tab
BackHandler intercepted back presses during the transition and
invisibly switched to the Chats tab. Gate it on the node's target
visibility in both the parent and Home's own backstack.

* fix(healthchat): satisfy detekt, ktlint and konsist rules

Import alias order, composable parameter order in HomeView, and konsist presenter helper shape.

* ci: pass the real repository name to the screenshot recorder

recordScreenshots.yml set GITHUB_REPOSITORY from `secrets.GITHUB_REPOSITORY`.
No such secret exists on this fork, so the expression expands to an empty
string and overrides the built-in environment variable of the same name.
recordScreenshots.sh checks it up front and exits 1 with "No repo specified",
so the workflow could never record anything here.

Use the `github.repository` context instead, which is always populated.

Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F

* Update screenshots

* Revert "Update screenshots"

This reverts commit b99e0d643f.

* Reapply "Update screenshots"

This reverts commit 85bceb6d67.

* test(screenshots): stop a LayoutLib teardown crash failing the shard tests

Paparazzi 2.0.0-alpha05 / LayoutLib 16.2.1 races on HandlerThread startup:
HandlerThread_Delegate.run calls Process.setThreadPriority, which throws
NoSuchMethodError for Thread.setPosixNicenessInternal and kills the
"android.bg" thread. If the test thread reaches getLooper() after that, it
gets null, BackgroundThread.ensureThreadReady() NPEs, and the static
initialiser of LatencyTracker fails. The JVM then marks that class erroneous
for the lifetime of the fork, so every later preview showing the soft
keyboard dies with NoClassDefFoundError.

LayoutLibErrorFilterStatement already swallowed the bare NoSuchMethodError,
but PaparazziLogger re-throws collected background errors from teardown()
wrapped in a MultipleFailuresException when there is more than one, and that
wrapper slipped through. A recent run reported 17 failures with zero image
comparisons failing and zero delta images written.

Unwrap the wrapper by reflection and swallow it only when every collected
cause matches the known-bug list; anything unrecognised, and anything that
cannot be decomposed, is rethrown.

Also close a hole this filter had from the start. Paparazzi runs the test as
`try { body } finally { teardown() }`, so a teardown error discards whatever
the body threw — including a genuine snapshot mismatch. renderFailureCapture
is registered inside the Paparazzi rule to record the body's exception so it
wins over any teardown error, and the filter refuses to swallow anything if
that rule is not registered. Two HorizontalFloatingToolbar previews have been
mismatching silently behind this, and may now surface as real failures.

Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F

---------

Co-authored-by: ElementBot <android@element.io>
2026-08-06 13:10:01 +02:00
joel.poyet 0c78553641 Merge pull request #16 from Novaloop-AG/ci/screenshot-fork-per-test-class
Give each screenshot test class its own JVM
2026-08-06 10:49:24 +02:00
joel.poyet 081904345a ci: give each screenshot test class its own JVM
There are five screenshot test classes (PreviewShard1..4Test plus
PreviewA11yTest) and each shard renders roughly 770 previews in a single
process. Gradle reuses a forked JVM for the next test class once
maxParallelForks is exhausted, so on the current six-core runners
(forks = 3) two classes run second, inside a process that has already
rendered a full shard.

That matches the observed failure exactly: PreviewShard2Test and
PreviewShard4Test fail 100% while shards 1 and 3 pass 100%, and the split
holds even for byte-identical preview states. The previous eight-core
runners computed forks = 4, reused only one JVM, and were green on this
same tree.

forkEvery = 1 restarts the JVM after every test class. It costs five JVM
startups and no extra peak memory, unlike raising maxParallelForks to 5,
which would need 10 GB of test heap on a 19 GiB runner with no swap.

Claude-Session: https://claude.ai/code/session_01MQbp1EPM4TSXq8P8Pyq44F
2026-08-06 10:41:22 +02:00
joel.poyet ce180ef25b Merge pull request #2 from Novaloop-AG/feature/healthchat-profile-fields
Show HealthChat profile fields on profile screens
2026-08-05 15:24:31 +02:00
joel.poyet c7800abb0f test(healthchat): satisfy konsist presenter helper rule
Konsist requires createPresenter helpers to omit the return type.
2026-08-05 14:13:04 +02:00
joel.poyet 1aeb4655bc feat(healthchat): show profile fields on profile screens
Renderer slot in UserProfileView (all three profile screens) shows
specialty, practice, address, contact links and further info from
the HealthChat API, with inline retry on failure.
2026-08-05 14:13:04 +02:00
joel.poyet 2db4c9847f Add HealthChat API service layer (#1)
* feat(healthchat): add HealthChat API service layer

New features/healthchat modules: typed Retrofit client with
per-request Matrix token auth, member directory, profile and
invite services, plus test fakes and fixtures.

* test(healthchat): use runCatchingExceptions

Detekt's RunCatchingNotAllowed rule forbids plain runCatching.
2026-08-05 14:13:00 +02:00
joel.poyet 621df1dedf Merge pull request #14 from Novaloop-AG/chore/rerecord-goldens-on-vm-runners
Re-record screenshot goldens on the new VM runners
2026-08-05 14:12:35 +02:00
ElementBot ac264ea08c Update screenshots 2026-08-04 22:30:06 +02:00
joel.poyet c0c0b48470 Merge pull request #9 from Novaloop-AG/feature/healthchat-remove-analytics-onboarding
Remove the analytics opt-in screen from onboarding
2026-08-04 18:42:11 +02:00
joel.poyet fa08091528 fix(lint): drop dead onboarding title overrides 2026-08-04 18:13:03 +02:00
joel.poyet 399adf2276 feat(analytics): disable analytics unless credentials are configured
Removes the onboarding opt-in screen, the Analytics settings entry and
the unused Posthog and Sentry SDKs.
2026-08-04 18:13:03 +02:00
joel.poyet 67fb6744fe Merge pull request #12 from Novaloop-AG/chore/rerecord-paparazzi-goldens
Re-record the stale Paparazzi screenshot goldens
2026-08-04 18:12:28 +02:00
joel.poyet 3e1010ba07 test(screenshots): re-record the 39 stale Paparazzi goldens
Several captured blank or half-laid-out screens; ShareLocation also lost its
map pin now that no MapTiler key is configured.
2026-08-04 17:40:13 +02:00
joel.poyet 1eb530e727 Merge pull request #13 from Novaloop-AG/ci/skip-codecov-upload
Skip the Codecov upload when no token is configured
2026-08-04 17:39:36 +02:00
joel.poyet 0cd363b1c3 ci: skip the codecov upload when no token is configured 2026-08-04 17:10:38 +02:00
joel.poyet 31611e5499 Merge pull request #11 from Novaloop-AG/fix/healthchat-launcher-icon-lint
Fix launcher icon lint errors in the HealthChat app icon
2026-08-04 16:08:20 +02:00
joel.poyet fdf142db60 fix(icon): ship the monochrome launcher icons as webp
Matches upstream's format, which is what tools/lint/lint.xml already
exempts from IconExpectedSize.
2026-08-04 14:57:23 +02:00
joel.poyet 96db91de86 Merge pull request #10 from Novaloop-AG/fix/ci-runners-and-permissions
Move releases to the persistent self-hosted runners and fix CI checkout
2026-08-04 14:45:50 +02:00
joel.poyet e74db93344 ci: cap Gradle workers so test JVMs fit the container 2026-08-04 13:36:52 +02:00
joel.poyet 8c4ef9264b ci: record why the Gradle heap is reduced 2026-08-04 12:55:48 +02:00
joel.poyet 409a1e5b90 ci: report runner capacity on the self-hosted jobs 2026-08-04 12:43:27 +02:00
joel.poyet 2013b86dfe ci: size the Gradle heap for the self-hosted containers
Upstream's -Xmx8g/4g is sized for hosted VMs; here the daemon is SIGKILLed
and Gradle reports "the daemon has disappeared".
2026-08-04 12:28:42 +02:00
joel.poyet cedd070bfe ci: rely on the runner image for the Android SDK
Drops the setup-android step from the release job and gates the swapfile
resize, which needs a hosted runner image and passwordless sudo.
2026-08-04 11:04:07 +02:00
joel.poyet 0fcd7bcfce ci: run the heavy Gradle jobs on the self-hosted runners
Requires the Android SDK in the runner image. Also gates the disk-cleanup
step to hosted runners, where it would otherwise strip the host.
2026-08-04 08:42:36 +02:00
joel.poyet 0799ad5b73 ci: disable the Label Community PRs job on this fork 2026-08-04 08:07:51 +02:00
joel.poyet 9475ec3a48 ci: grant contents:read so checkout works on this private fork 2026-08-04 07:11:37 +02:00
joel.poyet 2221bf2972 ci(release): move releases to the persistent self-hosted runners
novaloop-incus-large is the GARM ephemeral pool, whose runners were reaped
mid-job; [self-hosted, Linux, X64] matches four persistent org runners.
2026-08-04 07:11:37 +02:00
joel.poyet 05df27fc3a Merge pull request #8 from Novaloop-AG/fix/play-upload-review-flag
Fix Play Store upload rejected for changesNotSentForReview
2026-08-03 14:45:36 +02:00
joel.poyet b4629fff90 fix(release): drop changesNotSentForReview from Play upload
Play now sends this app's changes for review automatically and the Edits
API rejects the parameter, so the AAB uploaded but the edit never committed.
2026-08-03 13:44:34 +02:00
joel.poyet 7bbbde76d1 Merge pull request #7 from Novaloop-AG/chore/release-26.08.0
Set version 26.08.0
2026-08-03 13:26:25 +02:00
joel.poyet ec0303606a chore(release): set version 26.08.0 2026-08-03 11:27:07 +02:00
joel.poyet b7bfc6f751 Merge pull request #6 from Novaloop-AG/feature/healthchat-deeplinks
Point deeplinks and shared permalinks at HealthChat hosts
2026-08-03 11:10:28 +02:00
joel.poyet ada6fc457b feat(share): share user permalinks via to.healthchat.ch
Mention pills keep the matrix.to wire format other clients expect.
2026-08-03 10:53:51 +02:00
joel.poyet 57a2e585ba feat(deeplink): point deeplinks at HealthChat hosts
Claims app.healthchat.ch and to.healthchat.ch as App Links, registers the
healthchat:// scheme, and drops the unreachable mobile.element.io handling.
2026-08-03 10:53:35 +02:00
330 changed files with 8593 additions and 493 deletions
@@ -159,6 +159,31 @@ fixing the changelog over relying on the fallback.
- [ ] Internal testers get the update automatically if they're enrolled.
- [ ] AAB + `mapping.txt` are attached to the workflow run, retained 90 days.
**A green "Build Release AAB" step does not mean the release shipped.** The
upload step logs `Successfully uploaded 1 artifacts` before it commits the Play
edit; if the commit fails, the AAB is uploaded but no release exists on the
track. Always check the `Upload to Play Store` step, not just the job's build
steps.
## Troubleshooting: `changesNotSentForReview must not be set`
```
Successfully uploaded 1 artifacts
Committing the Edit
##[error]Changes are sent for review automatically.
The query parameter changesNotSentForReview must not be set.
```
Google flips this per app: once Play sends an app's changes for review
automatically, the Edits API rejects `changesNotSentForReview` instead of
ignoring it. The 26.07.0 release (2026-07-27) committed fine with the flag
set; 26.08.0 (2026-08-03) failed with it. The parameter is no longer passed —
if it ever needs to come back, gate it rather than setting it unconditionally.
Recovery: the failed edit is never committed, so the `versionCode` is not
consumed. Re-run via `workflow_dispatch` with the same version — no tag move
and no version bump needed.
## Required GitHub Actions secrets
The pipeline depends on these repository secrets. Missing any causes the
+20 -3
View File
@@ -7,18 +7,32 @@ on:
push:
branches: [ develop ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
build:
name: Build APKs
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
permissions:
# A job-level block replaces the workflow-level one, so contents:read has to be
# repeated here or checkout fails on this private fork.
contents: read
# For NejcZdovc/comment-pr
pull-requests: write
strategy:
@@ -31,6 +45,9 @@ jobs:
cancel-in-progress: true
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
+17 -3
View File
@@ -7,17 +7,28 @@ on:
push:
branches: [ develop ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
build:
name: Build Enterprise APKs
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
strategy:
@@ -30,6 +41,9 @@ jobs:
cancel-in-progress: true
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
+5 -1
View File
@@ -2,7 +2,11 @@ name: Danger CI
on: [pull_request, merge_group]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
jobs:
build:
@@ -46,17 +46,24 @@ concurrency:
env:
# Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the
# 4-core/16GB ubuntu-latest that public repos get. On this container the
# daemon keeps getting SIGKILLed mid-build (DaemonDisappearedException, no
# OutOfMemoryError, no heap dump), so stay well under upstream's ceiling and
# leave room for the Kotlin daemon and the parallel lint workers.
# 4-core/16GB ubuntu-latest that public repos get. On the previous incus
# container the daemon kept getting SIGKILLed mid-build
# (DaemonDisappearedException, no OutOfMemoryError, no heap dump), so these
# values stay well under upstream's ceiling and leave room for the Kotlin
# daemon and the parallel lint workers. Re-check them against the "Report
# runner capacity" output now that jobs land on the persistent org runners,
# whose specs may differ.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
release:
name: Build and upload AAB to Play Store
runs-on: novaloop-incus-large
# Persistent org runners (github-runner-01, gha-runner-1..3). The previous
# novaloop-incus-large label pointed at the GARM ephemeral pool, whose freshly
# spawned runners were reaped mid-job — jobs died ~10 minutes in with
# "The operation was canceled" and no other diagnostic.
runs-on: [self-hosted, Linux, X64]
steps:
- uses: actions/checkout@v4
with:
@@ -176,18 +183,14 @@ jobs:
distribution: 'temurin'
java-version: '21'
# The self-hosted runner image has no Android SDK (unlike GitHub-hosted
# ubuntu-latest), so install it here.
# - platform: follows COMPILE_SDK in plugins/src/main/kotlin/Versions.kt.
# Platform packages are minor-versioned, so compileSdk 37 is android-37.0.
# - build-tools: nothing sets buildToolsVersion, so AGP picks its own
# default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION). Bump it when AGP is
# upgraded, not when Versions.kt changes.
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-37.0 build-tools;36.0.0'
# The Android SDK now comes from the self-hosted runner image, so there is no
# setup-android step here. The image must provide it AND export ANDROID_HOME to
# the job environment — Gradle fails with "SDK location not found" otherwise.
# The image also has to carry the platform matching COMPILE_SDK in
# plugins/src/main/kotlin/Versions.kt (platform packages are minor-versioned, so
# compileSdk 37 means android-37.0) and the build-tools version AGP defaults to
# (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), since nothing sets buildToolsVersion
# here. Both need refreshing in the image when AGP or COMPILE_SDK moves.
- name: Configure gradle
uses: gradle/actions/setup-gradle@v4
@@ -238,4 +241,8 @@ jobs:
status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }}
whatsNewDirectory: fastlane/metadata/android
mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt
changesNotSentForReview: true
# Do NOT set changesNotSentForReview. Play now sends this app's changes for review
# automatically, and the Edits API rejects the parameter outright when it does:
# "Changes are sent for review automatically. The query parameter
# changesNotSentForReview must not be set." The AAB uploads fine and then the commit
# fails, so the release silently never lands on the track. See SKILL.md.
+17 -3
View File
@@ -5,11 +5,22 @@ on:
workflow_dispatch:
pull_request:
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
ARCH: x86_64
DEVICE: pixel_7_pro
@@ -19,12 +30,15 @@ env:
jobs:
build-apk:
name: Build APK
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
concurrency:
group: ${{ format('maestro-build-{0}', github.ref) }}
cancel-in-progress: true
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
+32 -27
View File
@@ -23,33 +23,38 @@ jobs:
script: |
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
community-prs:
name: Label Community PRs
runs-on: ubuntu-latest
if: github.event.action == 'opened'
permissions:
pull-requests: write
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
organization: element-hq
team: Vector Core
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['Z-Community-PR']
});
# Disabled on the HealthChat fork. This job asks whether the PR author belongs to the
# element-hq "Vector Core" team, using secrets.ELEMENT_BOT_TOKEN_READ_ORG — a secret
# that only exists upstream. Here it fails on every PR that is opened, and there is no
# community to label anyway. Kept commented rather than deleted so upstream changes to
# it still conflict visibly on merge.
# community-prs:
# name: Label Community PRs
# runs-on: ubuntu-latest
# if: github.event.action == 'opened'
# permissions:
# pull-requests: write
# steps:
# - name: Check membership
# if: github.event.pull_request.user.login != 'renovate[bot]'
# uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
# id: teams
# with:
# username: ${{ github.event.pull_request.user.login }}
# organization: element-hq
# team: Vector Core
# GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
# - name: Add label
# if: steps.teams.outputs.isTeamMember == 'false'
# uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# with:
# script: |
# github.rest.issues.addLabels({
# issue_number: context.issue.number,
# owner: context.repo.owner,
# repo: context.repo.repo,
# labels: ['Z-Community-PR']
# });
close-if-fork-develop:
name: Forbid develop branch fork contributions
+35 -8
View File
@@ -7,11 +7,22 @@ on:
push:
branches: [ main, develop ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
@@ -20,6 +31,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
@@ -63,7 +77,7 @@ jobs:
checkDependencies:
name: Search for invalid dependencies
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@@ -87,7 +101,7 @@ jobs:
# Code checks
konsist:
name: Konsist tests
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
@@ -128,12 +142,21 @@ jobs:
compose:
name: Compose tests
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
cancel-in-progress: true
steps:
# Records the container's real limits so heap settings can be read against them
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
- name: Report runner capacity
if: runner.environment == 'self-hosted'
run: |
free -h; nproc; df -h .
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit visible"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
@@ -162,7 +185,7 @@ jobs:
lint:
name: Android lint check
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
@@ -207,7 +230,7 @@ jobs:
detekt:
name: Detekt checks
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
@@ -248,7 +271,7 @@ jobs:
ktlint:
name: Ktlint checks
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
@@ -331,6 +354,10 @@ jobs:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
# A job-level block replaces the workflow-level one, so contents:read has to be
# repeated here or checkout fails on this private fork.
contents: read
actions: read # Required for zizmor to read workflow files.
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+27 -5
View File
@@ -5,12 +5,27 @@ on:
pull_request:
types: [ labeled ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dsonar.gradle.skipCompile=true
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
# Kept character-identical to tests.yml so the record path and the verify path cannot drift
# apart again. These only affect the Gradle daemon — Paparazzi renders in forked test workers
# configured entirely by build.gradle.kts — but keeping them aligned removes a whole class of
# "did record and verify run the same way?" questions.
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
jobs:
record:
@@ -19,11 +34,14 @@ jobs:
pull-requests: write
contents: write
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
@@ -68,5 +86,9 @@ jobs:
run: ./.github/workflows/scripts/recordScreenshots.sh
env:
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
# Must be the `github.repository` context, not a secret. There is no
# GITHUB_REPOSITORY secret on this fork, so `secrets.GITHUB_REPOSITORY` expands to ""
# and overrides the built-in env var of the same name — recordScreenshots.sh then
# exits 1 with "No repo specified" before Gradle ever runs.
GITHUB_REPOSITORY: ${{ github.repository }}
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
+17 -3
View File
@@ -7,24 +7,38 @@ on:
push:
branches: [ main, develop ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true --no-configuration-cache
GROUP: ${{ format('sonar-{0}', github.ref) }}
jobs:
sonar:
name: Sonar Quality Checks
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ format('sonar-{0}', github.ref) }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
steps:
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
+43 -4
View File
@@ -7,24 +7,53 @@ on:
push:
branches: [ main, develop ]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
# but the containers are oversubscribed against their host, and two concurrent heavy
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
# make two builds likelier to coexist; the real fix is sizing the containers to the
# host. See the "Report runner capacity" step.
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
# --max-workers caps how many test tasks run at once. build.gradle.kts sets
# maxParallelForks = cores/2 and screenshot tests fork at maxHeapSize=2g, so on these
# 8-core containers the default (workers = cores = 8) allows 8 x 4 x 2g = 64 GB of test
# JVMs against a 48 GiB cgroup limit, with no swap — the daemon gets OOM-killed and
# Gradle reports "the daemon has disappeared". GitHub's 4-core hosted runners reach only
# 2 x 2 x 2g = 16 GB and additionally have the 8 GB swapfile added above.
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
jobs:
tests:
name: Runs unit tests
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
# Records the container's real limits so heap settings can be read against them
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
- name: Report runner capacity
if: runner.environment == 'self-hosted'
run: |
free -h; nproc; df -h .
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit visible"
- name: Free Disk Space (Ubuntu)
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
# would delete dotnet/haskell/docker images from the host on every run.
if: runner.environment == 'github-hosted'
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
@@ -41,6 +70,9 @@ jobs:
# Increase swapfile size to prevent screenshot tests getting terminated
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
- name: 💽 Increase swapfile size
# /mnt/swapfile is a GitHub runner-image path and this needs passwordless sudo,
# so it only applies to the hosted VMs.
if: runner.environment == 'github-hosted'
run: |
sudo swapoff -a
sudo fallocate -l 8G /mnt/swapfile
@@ -107,7 +139,14 @@ jobs:
echo "---" >> $GITHUB_STEP_SUMMARY
# https://github.com/codecov/codecov-action
# This fork has no CODECOV_TOKEN, and Codecov rejects tokenless uploads from private
# repositories ("Token required - not valid tokenless upload"). With
# fail_ci_if_error: true that failed the whole job *after* the tests had passed, so
# skip the upload unless a token is configured. Adding the secret re-enables it.
- name: ☂️ Upload coverage reports to codecov
if: env.CODECOV_TOKEN != ''
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
+5 -1
View File
@@ -2,7 +2,11 @@ name: Validate Git LFS
on: [pull_request, merge_group]
permissions: {}
permissions:
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
# where an empty token can still read the repo, so an empty permissions block works there and
# fails here with `fatal: repository not found`.
contents: read
jobs:
build:
+14
View File
@@ -6,6 +6,20 @@ top section matching the released version — the heading must have the exact
form `## Changes in <version> (<date>)` and the whole section must stay under
500 characters. See `.claude/skills/releasing-healthchat/SKILL.md`.
## Changes in 26.08.0 (2026-08-06)
🎉 Neue Funktionen
- Mitgliederverzeichnis: Alle Mitglieder durchsuchen und direkt einen Chat starten.
- Profil bearbeiten: Angaben und Profilbild direkt in der App pflegen.
- Neue Mitglieder per E-Mail einladen.
- Statushinweise zeigen den Stand der Kontofreigabe.
🙌 Verbesserungen
- HealthChat-Links öffnen sich neu direkt in der App statt im Browser.
- Geteilte Profillinks verweisen neu auf HealthChat statt auf Element.
## Changes in 26.07.0 (2026-07-16)
🎉 Komplett neue App-Generation
+13 -27
View File
@@ -52,7 +52,7 @@
Using launchMode singleTask to avoid multiple instances of the Activity
when the app is already open. This is important for incoming share (see
https://github.com/element-hq/element-x-android/issues/4074) and for opening
the application from a mobile.element.io link.
the application from a permalink.
-->
<activity
android:name=".MainActivity"
@@ -72,7 +72,7 @@
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="open"
android:scheme="elementx" />
android:scheme="healthchat" />
</intent-filter>
<!--
OAuth redirection
@@ -86,7 +86,9 @@
<data android:scheme="@string/login_redirect_scheme" />
</intent-filter>
<!--
Element web links
HealthChat web links
Note: hosts are listed one by one on purpose, a "*.healthchat.ch" wildcard would
also intercept the homeserver and authentication domains.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
@@ -95,28 +97,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Note: we can't use "*.element.io" here because it'll intercept the "mas.element.io" domain too. -->
<!-- Matching asset file: https://app.element.io/.well-known/assetlinks.json -->
<data android:host="app.element.io" />
<!-- Matching asset file: https://develop.element.io/.well-known/assetlinks.json -->
<data android:host="develop.element.io" />
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
<data android:host="staging.element.io" />
</intent-filter>
<!--
Element mobile links
Example: https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
<data android:host="mobile.element.io" />
<data android:path="/element/" />
<!-- HealthChat web client permalinks, example: https://app.healthchat.ch/#/room/#room:healthchat.ch -->
<!-- Matching asset file: https://app.healthchat.ch/.well-known/assetlinks.json -->
<data android:host="app.healthchat.ch" />
<!-- HealthChat matrix.to deployment, example: https://to.healthchat.ch/#/#room:healthchat.ch -->
<!-- Matching asset file: https://to.healthchat.ch/.well-known/assetlinks.json -->
<data android:host="to.healthchat.ch" />
</intent-filter>
<!--
matrix: links
@@ -130,7 +116,7 @@
<data android:scheme="matrix" />
</intent-filter>
<!--
links from matrix.to website
links from the HealthChat matrix.to website, example: healthchat://room/%23room%3Ahealthchat.ch
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -138,7 +124,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:scheme="healthchat" />
<data android:host="user" />
<data android:host="room" />
</intent-filter>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Novaloop AG
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Willkommen bei HealthChat"</string>
</resources>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Novaloop AG
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Bienvenue chez HealthChat"</string>
</resources>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Novaloop AG
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Benvenuto su HealthChat"</string>
</resources>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Novaloop AG
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- HealthChat override of the Element onboarding title ("Be in your element").
Overridden here app-side instead of editing the Localazy-managed login
strings, so upstream merges stay clean. Only the main HealthChat locales
are overridden; other locales fall back to their Element translation. -->
<string name="screen_onboarding_welcome_title" tools:ignore="MissingTranslation">"Welcome to HealthChat"</string>
</resources>
@@ -10,5 +10,19 @@ package io.element.android.appconfig
object MatrixConfiguration {
const val MATRIX_TO_PERMALINK_BASE_URL: String = "https://matrix.to/#/"
/**
* HealthChat's own matrix.to deployment (https://github.com/Novaloop-AG/healthchat-matrix.to).
* It uses the exact same URL format as matrix.to, so links can be translated by swapping the
* base URL. Used both to recognise incoming links and to build links shared outside of Matrix.
*/
const val HEALTHCHAT_TO_PERMALINK_BASE_URL: String = "https://to.healthchat.ch/#/"
/**
* Base URL of the HealthChat web client, which is what the healthchat:// custom scheme
* handed out by [HEALTHCHAT_TO_PERMALINK_BASE_URL] points to.
*/
const val HEALTHCHAT_WEB_BASE_URL: String = "https://app.healthchat.ch/#/"
val clientPermalinkBaseUrl: String? = null
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -373,6 +373,11 @@ class LoggedInFlowNode(
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
// HealthChat fork: open a user profile from the member directory tab.
override fun navigateToUserProfile(userId: UserId) {
backstack.push(NavTarget.UserProfile(userId))
}
}
homeEntryPoint.createNode(
parentNode = this,
+11
View File
@@ -156,6 +156,17 @@ allprojects {
if (isScreenshotTest) {
// Increase heap size for screenshot tests
maxHeapSize = "2g"
// Give every screenshot test class its own JVM.
//
// There are 5 screenshot classes (PreviewShard1..4Test + PreviewA11yTest) and each
// shard renders ~770 previews in a single process. Gradle reuses a forked JVM for the
// next class once maxParallelForks is exhausted, so on a 6-core runner (forks = 3) two
// classes run second, in a process that has already rendered a full shard — and they
// render differently from how the goldens were recorded. That is exactly the observed
// failure: shards 2 and 4 fail 100% while 1 and 3 pass 100%, deterministically, for
// byte-identical states. The 8-core runner (forks = 4) reused only one JVM and was green
// on the same tree. Restarting per class costs 5 JVM startups and no extra peak memory.
forkEvery = 1L
// Record all the languages?
if (project.hasProperty("allLanguagesNoEnglish")) {
// Do not record English language
+40 -20
View File
@@ -1,4 +1,4 @@
# Element X Android deeplink
# HealthChat Android deeplink
<!--- TOC -->
@@ -12,31 +12,45 @@
## Introduction
Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android.
HealthChat Android supports deep linking to specific screens in the application. This document explains how to use deep links in HealthChat Android.
### Asset Links
The asset links file is available at https://element.io/.well-known/assetlinks.json
Two hosts are claimed as verified Android App Links, and each one must serve a matching asset links
file listing `ch.healthchat.element` and the SHA-256 of the Play app signing certificate:
| Host | Asset links file | Served from |
|---|---|---|
| `app.healthchat.ch` | https://app.healthchat.ch/.well-known/assetlinks.json | the `element-web` fork (`apps/web/res/.well-known/assetlinks.json`) |
| `to.healthchat.ch` | https://to.healthchat.ch/.well-known/assetlinks.json | the `healthchat-matrix.to` fork (generated by `scripts/build.js`) |
### Supported links
Element Call link:
> https://call.element.io/Example
Link to a user:
> https://app.element.io/#/user/@alice:matrix.org
> https://to.healthchat.ch/#/@alice:healthchat.ch
> https://app.healthchat.ch/#/user/@alice:healthchat.ch
Link to a room by id or alias:
> https://app.element.io/#/room/!roomid:matrix.org
> https://app.element.io/#/room/#element-x-android:matrix.org
> https://to.healthchat.ch/#/!roomid:healthchat.ch
> https://app.healthchat.ch/#/room/#healthchat:healthchat.ch
Link to a room with a specific event:
> https://app.element.io/#/room/!roomid:matrix.org/$eventid
> https://app.healthchat.ch/#/room/!roomid:healthchat.ch/$eventid
Note that it will also work with other domain such as:
> https://mobile.element.io
> https://develop.element.io
> https://staging.element.io
The HealthChat matrix.to deployment hands out a custom scheme to Android, which is registered too:
> healthchat://room/%23healthchat%3Ahealthchat.ch
> healthchat://user/%40alice%3Ahealthchat.ch
Standard Matrix URIs also work:
> matrix:r/healthchat:healthchat.ch
Only the two hosts above are declared in the manifest, so only those open the app from a browser.
`DefaultMatrixToConverter` is host-agnostic though: any URL using the `/#/room/`, `/#/user/` or
`/#/group/` path, as well as plain matrix.to links, are still resolved once they reach the app
(for instance when tapped inside a message).
Finally, notifications use an internal deep link that is not browsable:
> healthchat://open/{sessionId}/{roomId}/{eventId}
## Developer tools
@@ -44,28 +58,34 @@ Using an Android 12 or higher emulator
Ensure links verification is enabled
```bash
adb shell am compat enable 175408749 io.element.android.x.debug
adb shell am compat enable 175408749 ch.healthchat.element.debug
```
Reset link verifications for the given package id
```bash
adb shell pm set-app-links --package io.element.android.x.debug 0 all
adb shell pm set-app-links --package ch.healthchat.element.debug 0 all
```
Force the package id links to be verified
```bash
adb shell pm verify-app-links --re-verify io.element.android.x.debug
adb shell pm verify-app-links --re-verify ch.healthchat.element.debug
```
Print the link verification of the package id
```bash
adb shell pm get-app-links io.element.android.x.debug
adb shell pm get-app-links ch.healthchat.element.debug
```
```
io.element.android.x.debug:
ch.healthchat.element.debug:
ID: e2ece472-c266-4bf0-829c-be79959a6270
Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E]
Domain verification state:
*.element.io: 1024
app.healthchat.ch: verified
to.healthchat.ch: verified
```
Verification only succeeds if the signature above is listed in the asset links files. Release builds
are signed by Google Play, so use the app signing certificate SHA-256 from the Play Console. For a
debug build, add the fingerprint of `app/signature/debug.keystore` (alias `androiddebugkey`) to both
files, or test with `healthchat://` links instead, which need no verification.
+23
View File
@@ -0,0 +1,23 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.healthchat.api"
}
dependencies {
api(libs.kotlinx.collections.immutable)
implementation(libs.coroutines.core)
api(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
api(projects.libraries.matrix.api)
}
@@ -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,
)
}
@@ -0,0 +1,69 @@
/*
* 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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
/**
* Typed error for all HealthChat API failures.
*
* Invariant: every failed [Result] returned by a HealthChat service carries a
* [HealthChatApiException] (never a raw Retrofit/OkHttp exception).
*
* [serverMessage] values are German texts generated by the server and are rendered verbatim;
* they can be null (e.g. a bare 403 Forbid has no body), in which case the UI falls back
* to a localized string.
*/
sealed class HealthChatApiException(
override val message: String,
override val cause: Throwable?,
) : Exception(message, cause) {
/** HTTP 401: the Matrix access token was rejected. */
data class Unauthorized(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "unauthorized", null)
/** HTTP 403: the calling account is not approved (yet). */
data class NotApproved(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not approved", null)
/** HTTP 404: the requested resource does not exist. */
data class NotFound(
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "not found", null)
/** Any other HTTP error with the regular `{message}` error body shape. */
data class Http(
val status: Int,
val serverMessage: String?,
) : HealthChatApiException(serverMessage ?: "HTTP $status", null)
/**
* ASP.NET model validation error (`{title, status, errors: {Field: [msg]}}`).
*
* [fieldErrors] keys are lower-cased field names (e.g. "email", "personalnote").
*/
data class Validation(
val status: Int,
val title: String?,
val fieldErrors: ImmutableMap<String, ImmutableList<String>>,
) : HealthChatApiException(title ?: "validation failed", null)
/** The request never reached the server (I/O, timeout, no token). */
data class Network(
override val cause: Throwable,
) : HealthChatApiException("network error", cause)
/** Anything else (unparseable response, mapping failure, programming error). */
data class Unexpected(
override val cause: Throwable?,
) : HealthChatApiException("unexpected error", cause)
}
@@ -0,0 +1,26 @@
/*
* 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.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* Profile completeness as reported by the HealthChat API.
*
* [missingFields] contains German labels generated by the server and is rendered verbatim
* (with a best-effort localisation mapping on top).
*/
@Immutable
data class HealthChatCompleteness(
val isComplete: Boolean,
val missingFields: ImmutableList<String>,
val message: String?,
val hasProfilePhoto: Boolean,
)
@@ -0,0 +1,31 @@
/*
* 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 io.element.android.libraries.matrix.api.core.UserId
/**
* Stateless access to the HealthChat member directory.
*
* All failures are [HealthChatApiException]s. There is no caching: every call hits the API.
*/
interface HealthChatDirectoryService {
/**
* Search the member directory. A blank [query] returns the full directory.
* Search is server-side (name, practice, city, specialty, further info).
*/
suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory>
/**
* Fetch a single member by Matrix user id.
* Fails with [HealthChatApiException.NotApproved] when the calling account is not approved,
* and [HealthChatApiException.NotFound] when the user is not a directory member.
*/
suspend fun getMember(userId: UserId): Result<HealthChatMember>
}
@@ -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
}
@@ -0,0 +1,24 @@
/*
* 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
/**
* Invite a new member to HealthChat by email.
*
* All failures are [HealthChatApiException]s; server-side field validation surfaces as
* [HealthChatApiException.Validation] with lower-cased field keys ("email", "personalnote").
*/
interface HealthChatInviteService {
/**
* @param email the invitee's email address.
* @param personalNote a personal message, at least 10 characters (server-enforced).
* @return the German confirmation message from the server.
*/
suspend fun invite(email: String, personalNote: String): Result<String>
}
@@ -0,0 +1,40 @@
/*
* 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.Immutable
import io.element.android.libraries.matrix.api.core.UserId
/**
* A member of the HealthChat directory, as returned by the HealthChat API.
*
* All profile fields are nullable: the server sanitizer blanks fields in list responses,
* and members may simply not have filled them in. Blank values are normalized to null.
*/
@Immutable
data class HealthChatMember(
val userId: UserId,
val displayName: String?,
/** Avatar as a mxc:// URI, resolved by the app's regular media loading. */
val avatarUrl: String?,
val title: String?,
val firstName: String?,
val lastName: String?,
val specialty: String?,
val practiceName: String?,
val businessCity: String?,
val furtherInfo: String?,
val businessEmail: String?,
val businessTel: String?,
val street: String?,
val houseNumber: String?,
val postalCode: String?,
val website: String?,
val isApproved: Boolean,
)
@@ -0,0 +1,27 @@
/*
* 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.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* The result of a HealthChat member directory query.
*
* When the calling account is not approved yet, [isApproved] is false, [members] only contains
* the server's default members and [pendingMessage] carries a German explanation from the server.
*/
@Immutable
data class HealthChatMemberDirectory(
val members: ImmutableList<HealthChatMember>,
val totalCount: Int,
val searchQuery: String,
val isApproved: Boolean,
val pendingMessage: String?,
)
@@ -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.api
import io.element.android.libraries.matrix.api.user.MatrixUser
/**
* Display name fallback chain, mirroring the web client:
* displayName → "title firstName lastName" → businessEmail → [fallback].
*/
fun HealthChatMember.bestDisplayName(fallback: String): String {
displayName?.takeIf { it.isNotBlank() }?.let { return it }
val composedName = listOfNotNull(title, firstName, lastName)
.filter { it.isNotBlank() }
.joinToString(" ")
if (composedName.isNotBlank()) return composedName
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
return fallback
}
/**
* The Matrix representation of a directory member, e.g. to start a DM through the app's
* regular flows.
*/
fun HealthChatMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
@@ -0,0 +1,54 @@
/*
* 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 io.element.android.libraries.architecture.AsyncData
import kotlinx.coroutines.flow.StateFlow
/**
* Access to the own HealthChat profile.
*
* The [StateFlow]s exist so that a successful write on one screen propagates to every other
* screen observing the profile (same role as `MatrixClient.userProfile`). They are in-memory
* only, never persisted, and never used as a fallback for failed requests.
*
* All failures are [HealthChatApiException]s.
*/
interface HealthChatProfileRepository {
/** The own profile. Updated by [refreshOwnProfile] and [updateOwnProfile]. */
val ownProfile: StateFlow<AsyncData<HealthChatMember>>
/** The own profile completeness. Updated by [refreshCompleteness]. */
val completeness: StateFlow<AsyncData<HealthChatCompleteness>>
suspend fun refreshOwnProfile(): Result<HealthChatMember>
suspend fun refreshCompleteness(): Result<HealthChatCompleteness>
/**
* Apply a partial profile update ([HealthChatProfileUpdate] semantics), then re-fetch the
* profile so [ownProfile] reflects the server-derived state (e.g. the display name).
*
* @return the German confirmation message from the server.
*/
suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String>
/**
* Upload a new avatar (max 2 MiB, image mime types only) and return its `mxc://` URL.
* On success [ownProfile] is refreshed.
*/
suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String>
/**
* Remove the current avatar. On success [ownProfile] is refreshed.
*
* @return the German confirmation message from the server.
*/
suspend fun deleteAvatar(): Result<String>
}
@@ -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.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.UserId
/**
* Renders the HealthChat profile fields (specialty, practice, contact details, …) for a user.
*
* Injected into the upstream profile screens as a slot so the fork's only upstream diff is
* one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`).
*/
fun interface HealthChatProfileSectionRenderer {
@Composable
fun Render(
userId: UserId,
isCurrentUser: Boolean,
modifier: Modifier,
)
}
@@ -0,0 +1,38 @@
/*
* 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
/**
* A partial update of the own HealthChat profile.
*
* Semantics per field (mirrors the HealthChat API contract):
* - `null`: the field is left untouched on the server.
* - `""`: the field is cleared on the server.
* - any other value: the field is set to that value.
*
* Writing [firstName]/[lastName] makes the server re-derive the Matrix display name and
* sync it to Zitadel; the client must never call `setDisplayName` itself.
*/
data class HealthChatProfileUpdate(
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val furtherInfo: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
) {
fun isEmpty(): Boolean = this == HealthChatProfileUpdate()
}
@@ -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.api
/**
* Fork-owned HealthChat strings (de/en/fr/it), maintained in the `strings_healthchat.xml`
* files under `features/healthchat/api/src/main/res`.
* They are intentionally NOT part of the Localazy workflow.
*/
typealias HealthChatStrings = R.string
@@ -0,0 +1,26 @@
/*
* 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.directory
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface MemberDirectoryEvents {
/**
* Fired by the view on first composition. The tab presenter runs from app start (it is
* composed by the Home presenter), so the first fetch is deferred until the tab is shown.
*/
data object LoadIfNeeded : MemberDirectoryEvents
/** Header refresh and "try again" — one load path. */
data object Refresh : MemberDirectoryEvents
data class StartDm(val matrixUser: MatrixUser) : MemberDirectoryEvents
data object ClearStartDmState : MemberDirectoryEvents
}
@@ -0,0 +1,38 @@
/*
* 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.directory
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Renders the member directory tab content. Injected into the Home screen as a slot so the
* fork's upstream diff stays minimal.
*
* Nullable callbacks hide their affordance entirely (e.g. profile editing and inviting only
* become available once the corresponding screens exist).
*/
fun interface MemberDirectoryRenderer {
@Composable
fun Render(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier,
)
}
@@ -0,0 +1,30 @@
/*
* 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.directory
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
data class MemberDirectoryState(
val searchQuery: TextFieldState,
val directory: AsyncData<HealthChatMemberDirectory>,
val startDmActionState: AsyncAction<RoomId>,
val eventSink: (MemberDirectoryEvents) -> Unit,
) {
/** Unknown-while-loading counts as approved so no banner flashes during the first load. */
val isApproved: Boolean
get() = directory.dataOrNull()?.isApproved != false
/** The verbatim German server explanation, only when the account is not approved. */
val pendingMessage: String?
get() = directory.dataOrNull()?.takeIf { !it.isApproved }?.pendingMessage
}
@@ -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.api.directory
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList
/**
* Sample state builders, shared by previews inside the healthchat feature and by the Home
* screen's previews (same pattern as `aDirectLogoutState` in `features/logout/api`).
*/
fun aMemberDirectoryState(
searchQuery: TextFieldState = TextFieldState(),
directory: AsyncData<HealthChatMemberDirectory> = AsyncData.Success(aMemberDirectory()),
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (MemberDirectoryEvents) -> Unit = {},
) = MemberDirectoryState(
searchQuery = searchQuery,
directory = directory,
startDmActionState = startDmActionState,
eventSink = eventSink,
)
fun aMemberDirectory(
members: List<HealthChatMember> = aMemberList(),
totalCount: Int = members.size,
searchQuery: String = "",
isApproved: Boolean = true,
pendingMessage: String? = null,
) = HealthChatMemberDirectory(
members = members.toImmutableList(),
totalCount = totalCount,
searchQuery = searchQuery,
isApproved = isApproved,
pendingMessage = pendingMessage,
)
fun aMemberList(): List<HealthChatMember> = listOf(
aDirectoryMember(),
aDirectoryMember(
userId = UserId("@luca.bianchi:healthchat.ch"),
displayName = "Dr. med. Luca Bianchi",
specialty = "Kardiologie",
practiceName = "Herzzentrum Bellevue",
businessCity = "Bern",
businessEmail = null,
businessTel = null,
),
aDirectoryMember(
userId = UserId("@nora.keller:healthchat.ch"),
displayName = null,
specialty = null,
practiceName = null,
businessCity = null,
businessEmail = "nora.keller@example.ch",
businessTel = null,
),
)
fun aDirectoryMember(
userId: UserId = UserId("@erika.muster:healthchat.ch"),
displayName: String? = "Dr. med. Erika Muster",
specialty: String? = "Allgemeinmedizin",
practiceName: String? = "Praxis Muster",
businessCity: String? = "Zürich",
businessEmail: String? = "praxis@muster.ch",
businessTel: String? = "+41 44 123 45 67",
isApproved: Boolean = true,
) = HealthChatMember(
userId = userId,
displayName = displayName,
avatarUrl = null,
title = null,
firstName = null,
lastName = null,
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = null,
businessEmail = businessEmail,
businessTel = businessTel,
street = null,
houseNumber = null,
postalCode = null,
website = null,
isApproved = isApproved,
)
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
~
~ Fork-owned HealthChat strings (German). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string>
<string name="healthchat_error_unknown">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</string>
<string name="healthchat_field_specialty">Fachgebiet</string>
<string name="healthchat_field_practice_name">Praxis / Unternehmen</string>
<string name="healthchat_field_address">Adresse</string>
<string name="healthchat_field_business_email">E-Mail (geschäftlich)</string>
<string name="healthchat_field_business_tel">Telefon (geschäftlich)</string>
<string name="healthchat_field_website">Webseite</string>
<string name="healthchat_field_further_info">Weitere Infos / Qualifikationen</string>
<string name="healthchat_profile_error_loading">Die HealthChat-Profildaten konnten nicht geladen werden.</string>
<string name="healthchat_profile_not_approved_hint">Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist.</string>
<string name="healthchat_screen_home_tab_members">Mitglieder</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d Mitglied</item>
<item quantity="other">%1$d Mitglieder</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Mein Profil</string>
<string name="healthchat_screen_member_directory_search_placeholder">Suche nach Name, Praxis, Ort oder Fachgebiet</string>
<string name="healthchat_screen_member_directory_start_chat">Chat starten</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Fehler beim Erstellen des Chats</string>
<string name="healthchat_screen_member_directory_empty">Es sind noch keine Mitglieder vorhanden.</string>
<string name="healthchat_screen_member_directory_empty_search">Keine Mitglieder entsprechen Ihrer Suche.</string>
<string name="healthchat_screen_member_directory_unknown_name">Unbekannt</string>
<string name="healthchat_screen_member_directory_pending_title">Konto wird geprüft</string>
<string name="healthchat_screen_member_directory_pending_message">Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.</string>
<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>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
~
~ Fork-owned HealthChat strings (French). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer.</string>
<string name="healthchat_error_unknown">Une erreur est survenue. Veuillez réessayer.</string>
<string name="healthchat_field_specialty">Spécialité</string>
<string name="healthchat_field_practice_name">Cabinet / Entreprise</string>
<string name="healthchat_field_address">Adresse</string>
<string name="healthchat_field_business_email">E-mail (professionnel)</string>
<string name="healthchat_field_business_tel">Téléphone (professionnel)</string>
<string name="healthchat_field_website">Site web</string>
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
<string name="healthchat_profile_error_loading">Les détails du profil HealthChat n’ont pas pu être chargés.</string>
<string name="healthchat_profile_not_approved_hint">Les détails du profil seront visibles dès que votre compte aura été approuvé.</string>
<string name="healthchat_screen_home_tab_members">Membres</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d membre</item>
<item quantity="many">%1$d membres</item>
<item quantity="other">%1$d membres</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Mon profil</string>
<string name="healthchat_screen_member_directory_search_placeholder">Rechercher par nom, cabinet, ville ou spécialité</string>
<string name="healthchat_screen_member_directory_start_chat">Démarrer une discussion</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Impossible de démarrer la discussion</string>
<string name="healthchat_screen_member_directory_empty">Il n’y a pas encore de membres.</string>
<string name="healthchat_screen_member_directory_empty_search">Aucun membre ne correspond à votre recherche.</string>
<string name="healthchat_screen_member_directory_unknown_name">Inconnu</string>
<string name="healthchat_screen_member_directory_pending_title">Compte en cours de vérification</string>
<string name="healthchat_screen_member_directory_pending_message">Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres.</string>
<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>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
~
~ Fork-owned HealthChat strings (Italian). NOT managed by Localazy.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">Si è verificato un errore di rete. Controlla la connessione e riprova.</string>
<string name="healthchat_error_unknown">Qualcosa è andato storto. Riprova.</string>
<string name="healthchat_field_specialty">Specializzazione</string>
<string name="healthchat_field_practice_name">Studio / Azienda</string>
<string name="healthchat_field_address">Indirizzo</string>
<string name="healthchat_field_business_email">E-mail (professionale)</string>
<string name="healthchat_field_business_tel">Telefono (professionale)</string>
<string name="healthchat_field_website">Sito web</string>
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
<string name="healthchat_profile_error_loading">Impossibile caricare i dettagli del profilo HealthChat.</string>
<string name="healthchat_profile_not_approved_hint">I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato.</string>
<string name="healthchat_screen_home_tab_members">Membri</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d membro</item>
<item quantity="many">%1$d membri</item>
<item quantity="other">%1$d membri</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">Il mio profilo</string>
<string name="healthchat_screen_member_directory_search_placeholder">Cerca per nome, studio, città o specializzazione</string>
<string name="healthchat_screen_member_directory_start_chat">Avvia chat</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Impossibile avviare la chat</string>
<string name="healthchat_screen_member_directory_empty">Non ci sono ancora membri.</string>
<string name="healthchat_screen_member_directory_empty_search">Nessun membro corrisponde alla tua ricerca.</string>
<string name="healthchat_screen_member_directory_unknown_name">Sconosciuto</string>
<string name="healthchat_screen_member_directory_pending_title">Account in fase di verifica</string>
<string name="healthchat_screen_member_directory_pending_message">Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri.</string>
<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>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
~
~ Fork-owned HealthChat strings. NOT managed by Localazy — edit directly and keep
~ values-de/, values-fr/ and values-it/ in sync.
-->
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<string name="healthchat_error_network">A network error occurred. Please check your connection and try again.</string>
<string name="healthchat_error_unknown">Something went wrong. Please try again.</string>
<string name="healthchat_field_specialty">Specialty</string>
<string name="healthchat_field_practice_name">Practice / Company</string>
<string name="healthchat_field_address">Address</string>
<string name="healthchat_field_business_email">Email (business)</string>
<string name="healthchat_field_business_tel">Phone (business)</string>
<string name="healthchat_field_website">Website</string>
<string name="healthchat_field_further_info">Further info / qualifications</string>
<string name="healthchat_profile_error_loading">The HealthChat profile details couldn’t be loaded.</string>
<string name="healthchat_profile_not_approved_hint">Profile details become visible once your account has been approved.</string>
<string name="healthchat_screen_home_tab_members">Members</string>
<plurals name="healthchat_screen_member_directory_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<string name="healthchat_screen_member_directory_my_profile">My profile</string>
<string name="healthchat_screen_member_directory_search_placeholder">Search by name, practice, city or specialty</string>
<string name="healthchat_screen_member_directory_start_chat">Start chat</string>
<string name="healthchat_screen_member_directory_error_starting_chat">Failed to start the chat</string>
<string name="healthchat_screen_member_directory_empty">There are no members yet.</string>
<string name="healthchat_screen_member_directory_empty_search">No members match your search.</string>
<string name="healthchat_screen_member_directory_unknown_name">Unknown</string>
<string name="healthchat_screen_member_directory_pending_title">Account under review</string>
<string name="healthchat_screen_member_directory_pending_message">Your account is currently under review. Once it has been approved you can see and search all members.</string>
<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>
+81
View File
@@ -0,0 +1,81 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.healthchat.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "HEALTHCHAT_API_BASE_URL",
value = System.getenv("HEALTHCHAT_API_BASE_URL")
?: readLocalProperty("healthchat.api.base.url")
?: "https://api.healthchat.ch",
)
}
}
setupDependencyInjection()
dependencies {
api(projects.features.healthchat.api)
implementation(libs.coroutines.core)
implementation(projects.features.startchat.api)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.okhttp)
implementation(libs.network.okhttp.logging)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
implementation(libs.network.retrofit.converter.serialization)
implementation(libs.serialization.json)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
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)
testCommonDependencies(libs, true)
testImplementation(libs.network.mockwebserver)
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)
}
@@ -0,0 +1,50 @@
/*
* 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 dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberDirectory
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultHealthChatDirectoryService(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatDirectoryService {
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getUsers(search = query.takeIf { it.isNotBlank() })
}
.map { it.toHealthChatMemberDirectory() }
}
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getUser(userId.value)
}
.flatMap { dto ->
dto.toHealthChatMemberOrNull()
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Member payload has no valid userId")
)
)
}
}
}
@@ -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))
}
}
@@ -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
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.healthchat.api.HealthChatInviteService
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.network.InvitationRequestDto
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultHealthChatInviteService(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatInviteService {
override suspend fun invite(email: String, personalNote: String): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.invite(InvitationRequestDto(email = email, personalNote = personalNote))
}
.map { it.message.orEmpty() }
}
}
@@ -0,0 +1,117 @@
/*
* 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 dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatCompleteness
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatProfileRepository
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
import io.element.android.features.healthchat.impl.network.toDto
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.SessionScope
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHealthChatProfileRepository(
private val sessionApi: HealthChatSessionApi,
private val jsonProvider: JsonProvider,
) : HealthChatProfileRepository {
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow.asStateFlow()
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow.asStateFlow()
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
ownProfileFlow.value = AsyncData.Loading(ownProfileFlow.value.dataOrNull())
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getProfile()
}
.flatMap { dto ->
dto.toHealthChatMemberOrNull()
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Own profile payload has no valid userId")
)
)
}
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
}
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
completenessFlow.value = AsyncData.Loading(completenessFlow.value.dataOrNull())
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.getProfileCompleteness()
}
.map { dto ->
HealthChatCompleteness(
isComplete = dto.isComplete,
missingFields = dto.missingFields.toImmutableList(),
message = dto.message?.takeIf { it.isNotBlank() },
hasProfilePhoto = dto.hasProfilePhoto,
)
}
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
}
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.updateProfile(update.toDto())
}
.map { it.message.orEmpty() }
// Re-fetch so ownProfile reflects the server-derived state (e.g. display name).
.onSuccess { refreshOwnProfile() }
}
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
val part = MultipartBody.Part.createFormData(
name = "file",
filename = fileName,
body = data.toRequestBody(mimeType.toMediaType()),
)
sessionApi.api.uploadAvatar(part)
}
.flatMap { dto ->
dto.mxcUrl
?.let { Result.success(it) }
?: Result.failure(
HealthChatApiException.Unexpected(
IllegalStateException("Avatar upload response has no mxcUrl")
)
)
}
.onSuccess { refreshOwnProfile() }
}
override suspend fun deleteAvatar(): Result<String> {
return runHealthChatApiCatching(jsonProvider()) {
sessionApi.api.deleteAvatar()
}
.map { it.message.orEmpty() }
.onSuccess { refreshOwnProfile() }
}
}
@@ -0,0 +1,20 @@
/*
* 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
/**
* HealthChat API configuration.
*
* The base URL defaults to production and can be overridden at build time via the
* `HEALTHCHAT_API_BASE_URL` environment variable or the `healthchat.api.base.url` entry
* in the (gitignored) `local.properties`, so a dev override never ships.
*/
internal object HealthChatApiConfig {
val baseUrl: String = BuildConfig.HEALTHCHAT_API_BASE_URL
}
@@ -0,0 +1,30 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.HealthChatStrings
/**
* User-facing message for a HealthChat failure: the verbatim German server message where one
* exists, localized fallbacks for local/unknown failures.
*/
@Composable
fun Throwable.healthChatUserMessage(): String {
return when (this) {
is HealthChatApiException.Network -> stringResource(HealthChatStrings.healthchat_error_network)
is HealthChatApiException.Unauthorized -> serverMessage
is HealthChatApiException.NotApproved -> serverMessage
is HealthChatApiException.NotFound -> serverMessage
is HealthChatApiException.Http -> serverMessage
else -> null
} ?: stringResource(HealthChatStrings.healthchat_error_unknown)
}
@@ -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
}
}
@@ -0,0 +1,31 @@
/*
* 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 dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.healthchat.impl.network.HealthChatApi
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Holds the single per-session [HealthChatApi] instance (one derived OkHttp client and
* Retrofit instance per session, shared by all HealthChat services).
*/
@Inject
@SingleIn(SessionScope::class)
class HealthChatSessionApi(
apiFactory: HealthChatApiFactory,
sessionId: SessionId,
) {
val api: HealthChatApi by lazy {
apiFactory.create(HealthChatApiConfig.baseUrl, sessionId)
}
}
@@ -0,0 +1,64 @@
/*
* 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.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(
private val accountStatusPresenter: HealthChatAccountStatusPresenter,
) : MemberDirectoryRenderer {
@Composable
override fun Render(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
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,
)
}
}
}
@@ -0,0 +1,93 @@
/*
* 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.directory
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 dev.zacsweers.metro.Inject
import io.element.android.features.healthchat.api.HealthChatDirectoryService
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.startchat.api.StartDMAction
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.matrix.api.core.RoomId
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class MemberDirectoryPresenter(
private val directoryService: HealthChatDirectoryService,
private val startDMAction: StartDMAction,
) : Presenter<MemberDirectoryState> {
@Composable
override fun present(): MemberDirectoryState {
val coroutineScope = rememberCoroutineScope()
val searchQuery = rememberTextFieldState()
// This presenter is composed from app start (by the Home presenter), so the first
// fetch is deferred until the tab is actually shown (LoadIfNeeded from the view).
var hasBeenRequested by rememberSaveable { mutableStateOf(false) }
var loadTrigger by rememberSaveable { mutableIntStateOf(0) }
var directory by remember { mutableStateOf<AsyncData<HealthChatMemberDirectory>>(AsyncData.Uninitialized) }
val startDmActionState = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
LaunchedEffect(hasBeenRequested, searchQuery.text, loadTrigger) {
if (!hasBeenRequested) return@LaunchedEffect
if (searchQuery.text.isNotEmpty()) {
// Effect restart on typing = debounce.
delay(300.milliseconds)
}
directory = AsyncData.Loading(directory.dataOrNull())
directoryService.searchMembers(searchQuery.text.toString()).fold(
onSuccess = { directory = AsyncData.Success(it) },
// No stale fallback: a failed (re)load always shows the error state.
onFailure = { directory = AsyncData.Failure(it) },
)
}
fun handleEvents(event: MemberDirectoryEvents) {
when (event) {
MemberDirectoryEvents.LoadIfNeeded -> {
hasBeenRequested = true
}
MemberDirectoryEvents.Refresh -> {
loadTrigger++
}
is MemberDirectoryEvents.StartDm -> coroutineScope.launch {
startDMAction.execute(
matrixUser = event.matrixUser,
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
actionState = startDmActionState,
)
}
MemberDirectoryEvents.ClearStartDmState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
}
}
return MemberDirectoryState(
searchQuery = searchQuery,
directory = directory,
startDmActionState = startDmActionState.value,
eventSink = ::handleEvents,
)
}
}
@@ -0,0 +1,42 @@
/*
* 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.directory
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.directory.aMemberDirectory
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
import io.element.android.libraries.architecture.AsyncData
open class MemberDirectoryStateProvider : PreviewParameterProvider<MemberDirectoryState> {
override val values: Sequence<MemberDirectoryState>
get() = sequenceOf(
aMemberDirectoryState(),
aMemberDirectoryState(directory = AsyncData.Uninitialized),
aMemberDirectoryState(directory = AsyncData.Loading(aMemberDirectory())),
aMemberDirectoryState(
directory = AsyncData.Failure(
HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer")
),
),
aMemberDirectoryState(
directory = AsyncData.Success(
aMemberDirectory(
members = emptyList(),
isApproved = false,
pendingMessage = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.",
)
),
),
aMemberDirectoryState(
directory = AsyncData.Success(aMemberDirectory(members = emptyList())),
),
)
}
@@ -0,0 +1,234 @@
/*
* 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.directory
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
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.compound.theme.ElementTheme
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvents
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.api.toMatrixUser
import io.element.android.features.healthchat.impl.directory.components.MemberCard
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryErrorContent
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryHeader
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryPendingBanner
import io.element.android.features.healthchat.impl.healthChatUserMessage
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MemberDirectoryView(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
onOpenDm: (RoomId) -> Unit,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
state.eventSink(MemberDirectoryEvents.LoadIfNeeded)
}
Box(modifier = modifier.fillMaxSize()) {
when (val directory = state.directory) {
AsyncData.Uninitialized -> FullScreenLoading()
is AsyncData.Loading -> if (directory.prevData == null) {
FullScreenLoading()
} else {
MemberDirectoryContent(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
isRefreshing = true,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
)
}
is AsyncData.Failure -> MemberDirectoryErrorContent(
message = directory.error.healthChatUserMessage(),
onRetry = { state.eventSink(MemberDirectoryEvents.Refresh) },
)
is AsyncData.Success -> MemberDirectoryContent(
state = state,
contentPadding = contentPadding,
lazyListState = lazyListState,
isRefreshing = false,
onMemberClick = onMemberClick,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = onInviteClick,
)
}
AsyncActionView(
async = state.startDmActionState,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_starting_chat),
)
},
onSuccess = onOpenDm,
errorMessage = { stringResource(HealthChatStrings.healthchat_screen_member_directory_error_starting_chat) },
onRetry = null,
onErrorDismiss = { state.eventSink(MemberDirectoryEvents.ClearStartDmState) },
confirmationDialog = { data ->
if (data is ConfirmingStartDmWithMatrixUser) {
CreateDmConfirmationBottomSheet(
matrixUser = data.matrixUser,
isUserIdentityUnknown = data.isUserIdentityUnknown,
onSendInvite = {
state.eventSink(MemberDirectoryEvents.StartDm(data.matrixUser))
},
onDismiss = {
state.eventSink(MemberDirectoryEvents.ClearStartDmState)
},
)
}
},
)
}
}
@Composable
private fun MemberDirectoryContent(
state: MemberDirectoryState,
contentPadding: PaddingValues,
lazyListState: LazyListState,
isRefreshing: Boolean,
onMemberClick: ((UserId) -> Unit)?,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val directory = state.directory.dataOrNull() ?: return
Column(modifier = modifier.fillMaxSize()) {
if (isRefreshing) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn(
state = lazyListState,
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize(),
) {
item(key = "header") {
MemberDirectoryHeader(
memberCount = directory.totalCount,
onMyProfileClick = onMyProfileClick,
onEditProfileClick = onEditProfileClick,
onInviteClick = if (state.isApproved) onInviteClick else null,
onRefreshClick = { state.eventSink(MemberDirectoryEvents.Refresh) },
)
}
if (!state.isApproved) {
item(key = "pending") {
MemberDirectoryPendingBanner(
message = state.pendingMessage
?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message),
)
}
} else {
item(key = "search") {
SearchField(
state = state.searchQuery,
placeholder = stringResource(HealthChatStrings.healthchat_screen_member_directory_search_placeholder),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
if (directory.members.isEmpty()) {
item(key = "empty") {
val message = if (state.searchQuery.text.isNotEmpty()) {
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty_search)
} else {
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty)
}
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
)
}
}
items(directory.members, key = { it.userId.value }) { member ->
MemberCard(
member = member,
onClick = onMemberClick?.let { callback -> { callback(member.userId) } },
onStartChatClick = { state.eventSink(MemberDirectoryEvents.StartDm(member.toMatrixUser())) },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}
@Composable
private fun FullScreenLoading(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryViewPreview(
@PreviewParameter(MemberDirectoryStateProvider::class) state: MemberDirectoryState
) = ElementPreview {
MemberDirectoryView(
state = state,
contentPadding = PaddingValues(),
lazyListState = rememberLazyListState(),
onOpenDm = {},
onMemberClick = {},
onMyProfileClick = {},
onEditProfileClick = null,
onInviteClick = null,
)
}
@@ -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.directory.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.features.healthchat.api.bestDisplayName
import io.element.android.features.healthchat.api.directory.aDirectoryMember
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun MemberCard(
member: HealthChatMember,
onClick: (() -> Unit)?,
onStartChatClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.let { base -> onClick?.let { base.clickable(onClick = it) } ?: base },
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val displayName = member.bestDisplayName(
fallback = stringResource(HealthChatStrings.healthchat_screen_member_directory_unknown_name),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(
avatarData = AvatarData(
id = member.userId.value,
name = displayName,
url = member.avatarUrl,
size = AvatarSize.UserPreference,
),
avatarType = AvatarType.User,
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = displayName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
member.specialty?.let {
Text(
text = it,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
member.practiceName?.let { MemberCardDetailRow(icon = CompoundIcons.Company(), text = it) }
member.businessCity?.let { MemberCardDetailRow(icon = CompoundIcons.LocationPin(), text = it) }
member.businessEmail?.let { MemberCardDetailRow(icon = CompoundIcons.Email(), text = it) }
member.businessTel?.let { MemberCardDetailRow(icon = CompoundIcons.VoiceCall(), text = it) }
Text(
text = member.userId.value,
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_start_chat),
onClick = onStartChatClick,
size = ButtonSize.Small,
)
}
}
}
@Composable
private fun MemberCardDetailRow(
icon: ImageVector,
text: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier.size(16.dp),
)
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
@PreviewsDayNight
@Composable
internal fun MemberCardPreview() = ElementPreview {
MemberCard(
member = aDirectoryMember(),
onClick = {},
onStartChatClick = {},
)
}
@@ -0,0 +1,63 @@
/*
* 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.directory.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.BigIcon
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.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MemberDirectoryErrorContent(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
BigIcon(style = BigIcon.Style.AlertSolid)
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Button(
text = stringResource(CommonStrings.action_retry),
onClick = onRetry,
)
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryErrorContentPreview() = ElementPreview {
MemberDirectoryErrorContent(
message = "Fehler beim Laden der Benutzer",
onRetry = {},
)
}
@@ -0,0 +1,97 @@
/*
* 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.directory.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.healthchat.api.HealthChatStrings
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.features.healthchat.api.R as HealthChatR
@Composable
fun MemberDirectoryHeader(
memberCount: Int,
onMyProfileClick: (() -> Unit)?,
onEditProfileClick: (() -> Unit)?,
onInviteClick: (() -> Unit)?,
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pluralStringResource(
HealthChatR.plurals.healthchat_screen_member_directory_member_count,
memberCount,
memberCount,
),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.weight(1f),
)
onMyProfileClick?.let {
TextButton(
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_my_profile),
onClick = it,
)
}
onEditProfileClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_edit_profile),
)
}
}
onInviteClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = CompoundIcons.UserAdd(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_invite_member),
)
}
}
IconButton(onClick = onRefreshClick) {
Icon(
imageVector = CompoundIcons.Restart(),
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_refresh),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryHeaderPreview() = ElementPreview {
MemberDirectoryHeader(
memberCount = 42,
onMyProfileClick = {},
onEditProfileClick = null,
onInviteClick = null,
onRefreshClick = {},
)
}
@@ -0,0 +1,44 @@
/*
* 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.directory.components
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.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
@Composable
fun MemberDirectoryPendingBanner(
message: String,
modifier: Modifier = Modifier,
) {
Announcement(
title = stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_title),
description = message,
type = AnnouncementType.Informative(),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@PreviewsDayNight
@Composable
internal fun MemberDirectoryPendingBannerPreview() = ElementPreview {
MemberDirectoryPendingBanner(
message = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.",
)
}
@@ -0,0 +1,24 @@
/*
* 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.directory.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
import io.element.android.features.healthchat.impl.directory.MemberDirectoryPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@BindingContainer
@ContributesTo(SessionScope::class)
interface MemberDirectoryModule {
@Binds
fun bindMemberDirectoryPresenter(presenter: MemberDirectoryPresenter): Presenter<MemberDirectoryState>
}
@@ -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
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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,
)
@@ -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,
)
@@ -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 = {},
)
}
@@ -0,0 +1,81 @@
/*
* 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.mapper
import io.element.android.features.healthchat.api.HealthChatApiException
import io.element.android.features.healthchat.impl.network.MessageResponseDto
import io.element.android.features.healthchat.impl.network.ValidationProblemDto
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
/**
* Runs an API call and maps every failure to a [HealthChatApiException].
* CancellationExceptions are rethrown by [runCatchingExceptions].
*/
suspend fun <T> runHealthChatApiCatching(json: Json, block: suspend () -> T): Result<T> {
return runCatchingExceptions { block() }
.mapFailure { it.toHealthChatApiException(json) }
}
/**
* Maps any throwable to the typed [HealthChatApiException] hierarchy, understanding both
* HealthChat error body shapes: the regular `{message}` and ASP.NET ValidationProblemDetails
* (`{title, status, errors}`, whose PascalCase field keys are lower-cased here).
*/
fun Throwable.toHealthChatApiException(json: Json): HealthChatApiException {
return when (this) {
is HealthChatApiException -> this
is HttpException -> toHealthChatHttpException(json)
is IOException -> HealthChatApiException.Network(this)
else -> HealthChatApiException.Unexpected(this)
}
}
private fun HttpException.toHealthChatHttpException(json: Json): HealthChatApiException {
val errorBody = try {
response()?.errorBody()?.string()?.takeIf { it.isNotBlank() }
} catch (ignored: IOException) {
null
}
val validation = errorBody
?.let { json.decodeOrNull<ValidationProblemDto>(it) }
?.takeIf { it.errors.isNotEmpty() }
if (validation != null) {
return HealthChatApiException.Validation(
status = code(),
title = validation.title,
fieldErrors = validation.errors.entries
.associate { (field, messages) -> field.lowercase() to messages.toImmutableList() }
.toImmutableMap(),
)
}
val serverMessage = errorBody
?.let { json.decodeOrNull<MessageResponseDto>(it) }
?.message
?.takeIf { it.isNotBlank() }
return when (code()) {
401 -> HealthChatApiException.Unauthorized(serverMessage)
403 -> HealthChatApiException.NotApproved(serverMessage)
404 -> HealthChatApiException.NotFound(serverMessage)
else -> HealthChatApiException.Http(code(), serverMessage)
}
}
private inline fun <reified T> Json.decodeOrNull(body: String): T? {
return try {
decodeFromString<T>(body)
} catch (ignored: Exception) {
null
}
}
@@ -0,0 +1,61 @@
/*
* 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.mapper
import io.element.android.features.healthchat.api.HealthChatMember
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
import io.element.android.features.healthchat.impl.network.MatrixUserDto
import io.element.android.features.healthchat.impl.network.MatrixUserSearchResultDto
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
/**
* Maps a member DTO to the domain model, or null when the record has no valid Matrix user id.
*
* The MXID is validated BEFORE constructing [UserId]: its init block crashes debug builds on
* malformed ids, so one bad server row must never take the app down. Matrix ids are safe to log.
*/
fun MatrixUserDto.toHealthChatMemberOrNull(): HealthChatMember? {
val validUserId = userId?.takeIf { MatrixPatterns.isUserId(it) }
if (validUserId == null) {
Timber.w("Dropping HealthChat member with invalid userId: $userId")
return null
}
return HealthChatMember(
userId = UserId(validUserId),
displayName = displayName?.takeIf { it.isNotBlank() },
avatarUrl = avatarMxcUrl?.takeIf { it.isNotBlank() },
title = title?.takeIf { it.isNotBlank() },
firstName = firstName?.takeIf { it.isNotBlank() },
lastName = lastName?.takeIf { it.isNotBlank() },
specialty = specialty?.takeIf { it.isNotBlank() },
practiceName = practiceName?.takeIf { it.isNotBlank() },
businessCity = businessCity?.takeIf { it.isNotBlank() },
furtherInfo = furtherInfo?.takeIf { it.isNotBlank() },
businessEmail = businessEmail?.takeIf { it.isNotBlank() },
businessTel = businessTel?.takeIf { it.isNotBlank() },
street = street?.takeIf { it.isNotBlank() },
houseNumber = houseNumber?.takeIf { it.isNotBlank() },
postalCode = postalCode?.takeIf { it.isNotBlank() },
website = website?.takeIf { it.isNotBlank() },
isApproved = isApproved,
)
}
fun MatrixUserSearchResultDto.toHealthChatMemberDirectory(): HealthChatMemberDirectory {
return HealthChatMemberDirectory(
members = users.mapNotNull { it.toHealthChatMemberOrNull() }.toImmutableList(),
totalCount = totalCount,
searchQuery = searchQuery.orEmpty(),
isApproved = isApproved,
pendingMessage = pendingMessage?.takeIf { it.isNotBlank() },
)
}
@@ -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.network
import kotlinx.serialization.Serializable
@Serializable
data class AvatarUploadResponseDto(
val mxcUrl: String? = null,
)
@@ -0,0 +1,61 @@
/*
* 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.network
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for the HealthChat API (`https://api.healthchat.ch`).
* Authentication is added by [HealthChatAuthInterceptor].
*/
interface HealthChatApi {
@GET("api/usermatrix/users")
suspend fun getUsers(
@Query("search") search: String?,
): MatrixUserSearchResultDto
@GET("api/usermatrix/users/{userId}")
suspend fun getUser(
@Path("userId") userId: String,
): MatrixUserDto
@GET("api/usermatrix/profile")
suspend fun getProfile(): MatrixUserDto
@PUT("api/usermatrix/profile")
suspend fun updateProfile(
@Body request: UpdateProfileRequestDto,
): MessageResponseDto
@GET("api/usermatrix/profile/completeness")
suspend fun getProfileCompleteness(): ProfileCompletenessDto
@Multipart
@POST("api/usermatrix/profile/avatar")
suspend fun uploadAvatar(
@Part file: MultipartBody.Part,
): AvatarUploadResponseDto
@DELETE("api/usermatrix/profile/avatar")
suspend fun deleteAvatar(): MessageResponseDto
@POST("api/usermatrix/invite")
suspend fun invite(
@Body request: InvitationRequestDto,
): MessageResponseDto
}
@@ -0,0 +1,50 @@
/*
* 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.network
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureTrailingSlash
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
import io.element.android.libraries.sessionstorage.api.SessionStore
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
interface HealthChatApiFactory {
fun create(baseUrl: String, sessionId: SessionId): HealthChatApi
}
@ContributesBinding(AppScope::class)
class DefaultHealthChatApiFactory(
private val okHttpClient: () -> OkHttpClient,
private val sessionStore: () -> SessionStore,
private val json: () -> JsonProvider,
) : HealthChatApiFactory {
override fun create(baseUrl: String, sessionId: SessionId): HealthChatApi {
val client = okHttpClient()
.newBuilder()
.apply {
// PII: member directory and profile bodies must never reach BODY-level HTTP
// logs, so the shared client's logging interceptor is dropped here.
interceptors().removeAll { it is DynamicHttpLoggingInterceptor }
addInterceptor(HealthChatAuthInterceptor(sessionId, sessionStore()))
}
.build()
return Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
.client(client)
.build()
.create(HealthChatApi::class.java)
}
}
@@ -0,0 +1,57 @@
/*
* 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.network
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
/**
* Adds `Authorization: Bearer <matrix access token>` to every HealthChat API request.
*
* The token is read FRESH from [SessionStore] on every request: the Rust SDK rotates the
* access token and writes it back through `RustClientSessionDelegate`, so caching it here
* would eventually send a stale token.
*
* `runBlocking` inside an OkHttp interceptor follows the in-repo precedent of
* [io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor]:
* interceptors run on OkHttp's own worker threads, never on the main thread.
*/
class HealthChatAuthInterceptor(
private val sessionId: SessionId,
private val sessionStore: SessionStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = runBlocking {
withTimeoutOrNull(TOKEN_READ_TIMEOUT) {
sessionStore.getSession(sessionId.value)?.accessToken
}
} ?: throw HealthChatTokenUnavailableException()
val request = chain.request()
.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(request)
}
companion object {
private val TOKEN_READ_TIMEOUT = 5.seconds
}
}
/**
* Thrown (as an [IOException] so OkHttp surfaces it as a network failure) when no access
* token is available for the session, e.g. right after a logout.
*/
class HealthChatTokenUnavailableException : IOException("No access token available for the HealthChat API")
@@ -0,0 +1,17 @@
/*
* 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.network
import kotlinx.serialization.Serializable
@Serializable
data class InvitationRequestDto(
val email: String,
val personalNote: String,
)
@@ -0,0 +1,40 @@
/*
* 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.network
import kotlinx.serialization.Serializable
/**
* A member record as returned by the HealthChat API (camelCase).
*
* Every field is defaulted so the client stays tolerant to additive server-side changes
* (the API contract guarantees only additive evolution).
*/
@Serializable
data class MatrixUserDto(
val userId: String? = null,
val displayName: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val title: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val furtherInfo: String? = null,
val avatarMxcUrl: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
// Server-side default is true for legacy accounts.
val isApproved: Boolean = true,
val isActive: Boolean = true,
)
@@ -0,0 +1,20 @@
/*
* 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.network
import kotlinx.serialization.Serializable
@Serializable
data class MatrixUserSearchResultDto(
val users: List<MatrixUserDto> = emptyList(),
val totalCount: Int = 0,
val searchQuery: String? = null,
val isApproved: Boolean = true,
val pendingMessage: String? = null,
)
@@ -0,0 +1,17 @@
/*
* 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.network
import kotlinx.serialization.Serializable
/** The regular HealthChat API response/error body: a single German message. */
@Serializable
data class MessageResponseDto(
val message: String? = null,
)
@@ -0,0 +1,20 @@
/*
* 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.network
import kotlinx.serialization.Serializable
@Serializable
data class ProfileCompletenessDto(
// Fail quiet: a missing flag must not nag the user with an "incomplete profile" banner.
val isComplete: Boolean = true,
val missingFields: List<String> = emptyList(),
val message: String? = null,
val hasProfilePhoto: Boolean = true,
)
@@ -0,0 +1,52 @@
/*
* 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.network
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
import kotlinx.serialization.Serializable
/**
* Diff-only profile update body.
*
* Serialized with `encodeDefaults = false` (the app's default Json), so `null` fields are
* OMITTED from the JSON (server: untouched) while an explicit `""` is serialized
* (server: clear the field). Guarded by a serializer test.
*/
@Serializable
data class UpdateProfileRequestDto(
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val specialty: String? = null,
val practiceName: String? = null,
val businessCity: String? = null,
val furtherInfo: String? = null,
val businessEmail: String? = null,
val businessTel: String? = null,
val street: String? = null,
val houseNumber: String? = null,
val postalCode: String? = null,
val website: String? = null,
)
fun HealthChatProfileUpdate.toDto() = UpdateProfileRequestDto(
title = title,
firstName = firstName,
lastName = lastName,
specialty = specialty,
practiceName = practiceName,
businessCity = businessCity,
furtherInfo = furtherInfo,
businessEmail = businessEmail,
businessTel = businessTel,
street = street,
houseNumber = houseNumber,
postalCode = postalCode,
website = website,
)
@@ -0,0 +1,22 @@
/*
* 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.network
import kotlinx.serialization.Serializable
/**
* ASP.NET model validation error body (`ValidationProblemDetails`) — a DIFFERENT shape from
* the regular `{message}` errors. Field keys arrive PascalCase (e.g. "Email", "PersonalNote").
*/
@Serializable
data class ValidationProblemDto(
val title: String? = null,
val status: Int? = null,
val errors: Map<String, List<String>> = emptyMap(),
)
@@ -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),
)
}
@@ -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())
}
@@ -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
}
@@ -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()
}
@@ -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,
)
}
}
@@ -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")
@@ -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,
)
@@ -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()
}
@@ -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 = {},
)
}

Some files were not shown because too many files have changed in this diff Show More