Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a907501c08 | |||
| 7f43998fa5 | |||
| edb3520c56 | |||
| 0c78553641 | |||
| 081904345a | |||
| ce180ef25b | |||
| c7800abb0f | |||
| 1aeb4655bc | |||
| 2db4c9847f | |||
| 621df1dedf | |||
| ac264ea08c | |||
| c0c0b48470 | |||
| fa08091528 | |||
| 399adf2276 | |||
| 67fb6744fe | |||
| 3e1010ba07 | |||
| 1eb530e727 | |||
| 0cd363b1c3 | |||
| 31611e5499 | |||
| fdf142db60 | |||
| 96db91de86 | |||
| e74db93344 | |||
| 8c4ef9264b | |||
| 409a1e5b90 | |||
| 2013b86dfe | |||
| cedd070bfe | |||
| 0fcd7bcfce | |||
| 0799ad5b73 | |||
| 9475ec3a48 | |||
| 2221bf2972 | |||
| 05df27fc3a | |||
| b4629fff90 | |||
| 7bbbde76d1 | |||
| ec0303606a | |||
| b7bfc6f751 |
@@ -159,6 +159,31 @@ fixing the changelog over relying on the fallback.
|
|||||||
- [ ] Internal testers get the update automatically if they're enrolled.
|
- [ ] Internal testers get the update automatically if they're enrolled.
|
||||||
- [ ] AAB + `mapping.txt` are attached to the workflow run, retained 90 days.
|
- [ ] 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
|
## Required GitHub Actions secrets
|
||||||
|
|
||||||
The pipeline depends on these repository secrets. Missing any causes the
|
The pipeline depends on these repository secrets. Missing any causes the
|
||||||
|
|||||||
@@ -7,18 +7,32 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ develop ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build APKs
|
name: Build APKs
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
permissions:
|
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
|
# For NejcZdovc/comment-pr
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
strategy:
|
strategy:
|
||||||
@@ -31,6 +45,9 @@ jobs:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||||
|
|||||||
@@ -7,17 +7,28 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ develop ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Enterprise APKs
|
name: Build Enterprise APKs
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
# Skip in forks
|
# Skip in forks
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
strategy:
|
strategy:
|
||||||
@@ -30,6 +41,9 @@ jobs:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ name: Danger CI
|
|||||||
|
|
||||||
on: [pull_request, merge_group]
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -46,17 +46,24 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the
|
# Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the
|
||||||
# 4-core/16GB ubuntu-latest that public repos get. On this container the
|
# 4-core/16GB ubuntu-latest that public repos get. On the previous incus
|
||||||
# daemon keeps getting SIGKILLed mid-build (DaemonDisappearedException, no
|
# container the daemon kept getting SIGKILLed mid-build
|
||||||
# OutOfMemoryError, no heap dump), so stay well under upstream's ceiling and
|
# (DaemonDisappearedException, no OutOfMemoryError, no heap dump), so these
|
||||||
# leave room for the Kotlin daemon and the parallel lint workers.
|
# 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
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Build and upload AAB to Play Store
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -176,18 +183,14 @@ jobs:
|
|||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
# The self-hosted runner image has no Android SDK (unlike GitHub-hosted
|
# The Android SDK now comes from the self-hosted runner image, so there is no
|
||||||
# ubuntu-latest), so install it here.
|
# setup-android step here. The image must provide it AND export ANDROID_HOME to
|
||||||
# - platform: follows COMPILE_SDK in plugins/src/main/kotlin/Versions.kt.
|
# the job environment — Gradle fails with "SDK location not found" otherwise.
|
||||||
# Platform packages are minor-versioned, so compileSdk 37 is android-37.0.
|
# The image also has to carry the platform matching COMPILE_SDK in
|
||||||
# - build-tools: nothing sets buildToolsVersion, so AGP picks its own
|
# plugins/src/main/kotlin/Versions.kt (platform packages are minor-versioned, so
|
||||||
# default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION). Bump it when AGP is
|
# compileSdk 37 means android-37.0) and the build-tools version AGP defaults to
|
||||||
# upgraded, not when Versions.kt changes.
|
# (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), since nothing sets buildToolsVersion
|
||||||
- name: Set up Android SDK
|
# here. Both need refreshing in the image when AGP or COMPILE_SDK moves.
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: 'platforms;android-37.0 build-tools;36.0.0'
|
|
||||||
|
|
||||||
- name: Configure gradle
|
- name: Configure gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
@@ -238,4 +241,8 @@ jobs:
|
|||||||
status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }}
|
status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }}
|
||||||
whatsNewDirectory: fastlane/metadata/android
|
whatsNewDirectory: fastlane/metadata/android
|
||||||
mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt
|
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.
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
ARCH: x86_64
|
ARCH: x86_64
|
||||||
DEVICE: pixel_7_pro
|
DEVICE: pixel_7_pro
|
||||||
@@ -19,12 +30,15 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-apk:
|
build-apk:
|
||||||
name: Build APK
|
name: Build APK
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ format('maestro-build-{0}', github.ref) }}
|
group: ${{ format('maestro-build-{0}', github.ref) }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||||
|
|||||||
@@ -23,33 +23,38 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
|
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
|
||||||
|
|
||||||
community-prs:
|
# Disabled on the HealthChat fork. This job asks whether the PR author belongs to the
|
||||||
name: Label Community PRs
|
# element-hq "Vector Core" team, using secrets.ELEMENT_BOT_TOKEN_READ_ORG — a secret
|
||||||
runs-on: ubuntu-latest
|
# that only exists upstream. Here it fails on every PR that is opened, and there is no
|
||||||
if: github.event.action == 'opened'
|
# community to label anyway. Kept commented rather than deleted so upstream changes to
|
||||||
permissions:
|
# it still conflict visibly on merge.
|
||||||
pull-requests: write
|
# community-prs:
|
||||||
steps:
|
# name: Label Community PRs
|
||||||
- name: Check membership
|
# runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
# if: github.event.action == 'opened'
|
||||||
uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
# permissions:
|
||||||
id: teams
|
# pull-requests: write
|
||||||
with:
|
# steps:
|
||||||
username: ${{ github.event.pull_request.user.login }}
|
# - name: Check membership
|
||||||
organization: element-hq
|
# if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||||
team: Vector Core
|
# uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
||||||
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
# id: teams
|
||||||
- name: Add label
|
# with:
|
||||||
if: steps.teams.outputs.isTeamMember == 'false'
|
# username: ${{ github.event.pull_request.user.login }}
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
# organization: element-hq
|
||||||
with:
|
# team: Vector Core
|
||||||
script: |
|
# GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
||||||
github.rest.issues.addLabels({
|
# - name: Add label
|
||||||
issue_number: context.issue.number,
|
# if: steps.teams.outputs.isTeamMember == 'false'
|
||||||
owner: context.repo.owner,
|
# uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
repo: context.repo.repo,
|
# with:
|
||||||
labels: ['Z-Community-PR']
|
# 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:
|
close-if-fork-develop:
|
||||||
name: Forbid develop branch fork contributions
|
name: Forbid develop branch fork contributions
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ main, develop ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -20,6 +31,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||||
@@ -63,7 +77,7 @@ jobs:
|
|||||||
|
|
||||||
checkDependencies:
|
checkDependencies:
|
||||||
name: Search for invalid dependencies
|
name: Search for invalid dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -87,7 +101,7 @@ jobs:
|
|||||||
# Code checks
|
# Code checks
|
||||||
konsist:
|
konsist:
|
||||||
name: Konsist tests
|
name: Konsist tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
# Allow all jobs on main and develop. Just one per PR.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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:
|
compose:
|
||||||
name: Compose tests
|
name: Compose tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
# Allow all jobs on main and develop. Just one per PR.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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
|
cancel-in-progress: true
|
||||||
steps:
|
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
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
# Ensure we are building the branch and not the branch after being merged on develop
|
# Ensure we are building the branch and not the branch after being merged on develop
|
||||||
@@ -162,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Android lint check
|
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.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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:
|
detekt:
|
||||||
name: Detekt checks
|
name: Detekt checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
# Allow all jobs on main and develop. Just one per PR.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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:
|
ktlint:
|
||||||
name: Ktlint checks
|
name: Ktlint checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64]
|
||||||
# Allow all jobs on main and develop. Just one per PR.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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
|
name: Run zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
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.
|
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|||||||
@@ -5,12 +5,27 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [ labeled ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||||
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
|
# 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:
|
jobs:
|
||||||
record:
|
record:
|
||||||
@@ -19,11 +34,14 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: write
|
contents: write
|
||||||
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
|
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'
|
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# 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
|
run: ./.github/workflows/scripts/recordScreenshots.sh
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
|
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 }}
|
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
|
||||||
|
|||||||
@@ -7,24 +7,38 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ main, develop ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
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
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||||
GROUP: ${{ format('sonar-{0}', github.ref) }}
|
GROUP: ${{ format('sonar-{0}', github.ref) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sonar:
|
sonar:
|
||||||
name: Sonar Quality Checks
|
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.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ format('sonar-{0}', github.ref) }}
|
group: ${{ format('sonar-{0}', github.ref) }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Free Disk Space (Ubuntu)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||||
|
|||||||
@@ -7,24 +7,53 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ main, develop ]
|
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
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
# 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:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
name: Runs unit 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.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
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) }}
|
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
|
cancel-in-progress: true
|
||||||
steps:
|
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)
|
- 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
|
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||||
with:
|
with:
|
||||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
# 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
|
# Increase swapfile size to prevent screenshot tests getting terminated
|
||||||
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
||||||
- name: 💽 Increase swapfile size
|
- 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: |
|
run: |
|
||||||
sudo swapoff -a
|
sudo swapoff -a
|
||||||
sudo fallocate -l 8G /mnt/swapfile
|
sudo fallocate -l 8G /mnt/swapfile
|
||||||
@@ -107,7 +139,14 @@ jobs:
|
|||||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# https://github.com/codecov/codecov-action
|
# 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
|
- name: ☂️ Upload coverage reports to codecov
|
||||||
|
if: env.CODECOV_TOKEN != ''
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ name: Validate Git LFS
|
|||||||
|
|
||||||
on: [pull_request, merge_group]
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -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
|
form `## Changes in <version> (<date>)` and the whole section must stay under
|
||||||
500 characters. See `.claude/skills/releasing-healthchat/SKILL.md`.
|
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)
|
## Changes in 26.07.0 (2026-07-16)
|
||||||
|
|
||||||
🎉 Komplett neue App-Generation
|
🎉 Komplett neue App-Generation
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -373,6 +373,11 @@ class LoggedInFlowNode(
|
|||||||
override fun navigateToBugReport() {
|
override fun navigateToBugReport() {
|
||||||
callback.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(
|
homeEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ allprojects {
|
|||||||
if (isScreenshotTest) {
|
if (isScreenshotTest) {
|
||||||
// Increase heap size for screenshot tests
|
// Increase heap size for screenshot tests
|
||||||
maxHeapSize = "2g"
|
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?
|
// Record all the languages?
|
||||||
if (project.hasProperty("allLanguagesNoEnglish")) {
|
if (project.hasProperty("allLanguagesNoEnglish")) {
|
||||||
// Do not record English language
|
// Do not record English language
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* 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.section
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.features.healthchat.api.HealthChatProfileSectionRenderer
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
class DefaultHealthChatProfileSectionRenderer(
|
||||||
|
private val presenterFactory: HealthChatProfileSectionPresenter.Factory,
|
||||||
|
) : HealthChatProfileSectionRenderer {
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
userId: UserId,
|
||||||
|
isCurrentUser: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val presenter = remember(userId, isCurrentUser) {
|
||||||
|
presenterFactory.create(userId = userId, isCurrentUser = isCurrentUser)
|
||||||
|
}
|
||||||
|
HealthChatProfileSectionView(
|
||||||
|
state = presenter.present(),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.section
|
||||||
|
|
||||||
|
sealed interface HealthChatProfileSectionEvents {
|
||||||
|
data object Retry : HealthChatProfileSectionEvents
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* 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.section
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||||
|
import io.element.android.features.healthchat.api.HealthChatMember
|
||||||
|
import io.element.android.features.healthchat.api.HealthChatProfileRepository
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
|
class HealthChatProfileSectionPresenter(
|
||||||
|
@Assisted private val userId: UserId,
|
||||||
|
@Assisted private val isCurrentUser: Boolean,
|
||||||
|
private val directoryService: HealthChatDirectoryService,
|
||||||
|
private val profileRepository: HealthChatProfileRepository,
|
||||||
|
) : Presenter<HealthChatProfileSectionState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(userId: UserId, isCurrentUser: Boolean): HealthChatProfileSectionPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): HealthChatProfileSectionState {
|
||||||
|
var retryTrigger by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
// isCurrentUser is fixed for the lifetime of the presenter, so the branch is stable.
|
||||||
|
val profile = if (isCurrentUser) {
|
||||||
|
val ownProfile by profileRepository.ownProfile.collectAsState()
|
||||||
|
LaunchedEffect(retryTrigger) {
|
||||||
|
profileRepository.refreshOwnProfile()
|
||||||
|
}
|
||||||
|
ownProfile
|
||||||
|
} else {
|
||||||
|
var memberProfile by remember {
|
||||||
|
mutableStateOf<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
|
||||||
|
}
|
||||||
|
LaunchedEffect(retryTrigger) {
|
||||||
|
memberProfile = AsyncData.Loading(memberProfile.dataOrNull())
|
||||||
|
directoryService.getMember(userId).fold(
|
||||||
|
onSuccess = { memberProfile = AsyncData.Success(it) },
|
||||||
|
onFailure = { memberProfile = AsyncData.Failure(it, memberProfile.dataOrNull()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
memberProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: HealthChatProfileSectionEvents) {
|
||||||
|
when (event) {
|
||||||
|
HealthChatProfileSectionEvents.Retry -> retryTrigger++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthChatProfileSectionState(
|
||||||
|
profile = profile,
|
||||||
|
eventSink = ::handleEvents,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||