Compare commits
37 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 | |||
| ada6fc457b | |||
| 57a2e585ba |
@@ -159,6 +159,31 @@ fixing the changelog over relying on the fallback.
|
||||
- [ ] Internal testers get the update automatically if they're enrolled.
|
||||
- [ ] AAB + `mapping.txt` are attached to the workflow run, retained 90 days.
|
||||
|
||||
**A green "Build Release AAB" step does not mean the release shipped.** The
|
||||
upload step logs `Successfully uploaded 1 artifacts` before it commits the Play
|
||||
edit; if the commit fails, the AAB is uploaded but no release exists on the
|
||||
track. Always check the `Upload to Play Store` step, not just the job's build
|
||||
steps.
|
||||
|
||||
## Troubleshooting: `changesNotSentForReview must not be set`
|
||||
|
||||
```
|
||||
Successfully uploaded 1 artifacts
|
||||
Committing the Edit
|
||||
##[error]Changes are sent for review automatically.
|
||||
The query parameter changesNotSentForReview must not be set.
|
||||
```
|
||||
|
||||
Google flips this per app: once Play sends an app's changes for review
|
||||
automatically, the Edits API rejects `changesNotSentForReview` instead of
|
||||
ignoring it. The 26.07.0 release (2026-07-27) committed fine with the flag
|
||||
set; 26.08.0 (2026-08-03) failed with it. The parameter is no longer passed —
|
||||
if it ever needs to come back, gate it rather than setting it unconditionally.
|
||||
|
||||
Recovery: the failed edit is never committed, so the `versionCode` is not
|
||||
consumed. Re-run via `workflow_dispatch` with the same version — no tag move
|
||||
and no version bump needed.
|
||||
|
||||
## Required GitHub Actions secrets
|
||||
|
||||
The pipeline depends on these repository secrets. Missing any causes the
|
||||
|
||||
@@ -7,18 +7,32 @@ on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build APKs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
permissions:
|
||||
# A job-level block replaces the workflow-level one, so contents:read has to be
|
||||
# repeated here or checkout fails on this private fork.
|
||||
contents: read
|
||||
# For NejcZdovc/comment-pr
|
||||
pull-requests: write
|
||||
strategy:
|
||||
@@ -31,6 +45,9 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -7,17 +7,28 @@ on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Enterprise APKs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
strategy:
|
||||
@@ -30,6 +41,9 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -2,7 +2,11 @@ name: Danger CI
|
||||
|
||||
on: [pull_request, merge_group]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -46,17 +46,24 @@ concurrency:
|
||||
|
||||
env:
|
||||
# Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the
|
||||
# 4-core/16GB ubuntu-latest that public repos get. On this container the
|
||||
# daemon keeps getting SIGKILLed mid-build (DaemonDisappearedException, no
|
||||
# OutOfMemoryError, no heap dump), so stay well under upstream's ceiling and
|
||||
# leave room for the Kotlin daemon and the parallel lint workers.
|
||||
# 4-core/16GB ubuntu-latest that public repos get. On the previous incus
|
||||
# container the daemon kept getting SIGKILLed mid-build
|
||||
# (DaemonDisappearedException, no OutOfMemoryError, no heap dump), so these
|
||||
# values stay well under upstream's ceiling and leave room for the Kotlin
|
||||
# daemon and the parallel lint workers. Re-check them against the "Report
|
||||
# runner capacity" output now that jobs land on the persistent org runners,
|
||||
# whose specs may differ.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and upload AAB to Play Store
|
||||
runs-on: novaloop-incus-large
|
||||
# Persistent org runners (github-runner-01, gha-runner-1..3). The previous
|
||||
# novaloop-incus-large label pointed at the GARM ephemeral pool, whose freshly
|
||||
# spawned runners were reaped mid-job — jobs died ~10 minutes in with
|
||||
# "The operation was canceled" and no other diagnostic.
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -176,18 +183,14 @@ jobs:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
# The self-hosted runner image has no Android SDK (unlike GitHub-hosted
|
||||
# ubuntu-latest), so install it here.
|
||||
# - platform: follows COMPILE_SDK in plugins/src/main/kotlin/Versions.kt.
|
||||
# Platform packages are minor-versioned, so compileSdk 37 is android-37.0.
|
||||
# - build-tools: nothing sets buildToolsVersion, so AGP picks its own
|
||||
# default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION). Bump it when AGP is
|
||||
# upgraded, not when Versions.kt changes.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-37.0 build-tools;36.0.0'
|
||||
|
||||
# The Android SDK now comes from the self-hosted runner image, so there is no
|
||||
# setup-android step here. The image must provide it AND export ANDROID_HOME to
|
||||
# the job environment — Gradle fails with "SDK location not found" otherwise.
|
||||
# The image also has to carry the platform matching COMPILE_SDK in
|
||||
# plugins/src/main/kotlin/Versions.kt (platform packages are minor-versioned, so
|
||||
# compileSdk 37 means android-37.0) and the build-tools version AGP defaults to
|
||||
# (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), since nothing sets buildToolsVersion
|
||||
# here. Both need refreshing in the image when AGP or COMPILE_SDK moves.
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
@@ -238,4 +241,8 @@ jobs:
|
||||
status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }}
|
||||
whatsNewDirectory: fastlane/metadata/android
|
||||
mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt
|
||||
changesNotSentForReview: true
|
||||
# Do NOT set changesNotSentForReview. Play now sends this app's changes for review
|
||||
# automatically, and the Edits API rejects the parameter outright when it does:
|
||||
# "Changes are sent for review automatically. The query parameter
|
||||
# changesNotSentForReview must not be set." The AAB uploads fine and then the commit
|
||||
# fails, so the release silently never lands on the track. See SKILL.md.
|
||||
|
||||
@@ -5,11 +5,22 @@ on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
ARCH: x86_64
|
||||
DEVICE: pixel_7_pro
|
||||
@@ -19,12 +30,15 @@ env:
|
||||
jobs:
|
||||
build-apk:
|
||||
name: Build APK
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
concurrency:
|
||||
group: ${{ format('maestro-build-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -23,33 +23,38 @@ jobs:
|
||||
script: |
|
||||
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
|
||||
|
||||
community-prs:
|
||||
name: Label Community PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action == 'opened'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check membership
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
organization: element-hq
|
||||
team: Vector Core
|
||||
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['Z-Community-PR']
|
||||
});
|
||||
# Disabled on the HealthChat fork. This job asks whether the PR author belongs to the
|
||||
# element-hq "Vector Core" team, using secrets.ELEMENT_BOT_TOKEN_READ_ORG — a secret
|
||||
# that only exists upstream. Here it fails on every PR that is opened, and there is no
|
||||
# community to label anyway. Kept commented rather than deleted so upstream changes to
|
||||
# it still conflict visibly on merge.
|
||||
# community-prs:
|
||||
# name: Label Community PRs
|
||||
# runs-on: ubuntu-latest
|
||||
# if: github.event.action == 'opened'
|
||||
# permissions:
|
||||
# pull-requests: write
|
||||
# steps:
|
||||
# - name: Check membership
|
||||
# if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
# uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
||||
# id: teams
|
||||
# with:
|
||||
# username: ${{ github.event.pull_request.user.login }}
|
||||
# organization: element-hq
|
||||
# team: Vector Core
|
||||
# GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
||||
# - name: Add label
|
||||
# if: steps.teams.outputs.isTeamMember == 'false'
|
||||
# uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# with:
|
||||
# script: |
|
||||
# github.rest.issues.addLabels({
|
||||
# issue_number: context.issue.number,
|
||||
# owner: context.repo.owner,
|
||||
# repo: context.repo.repo,
|
||||
# labels: ['Z-Community-PR']
|
||||
# });
|
||||
|
||||
close-if-fork-develop:
|
||||
name: Forbid develop branch fork contributions
|
||||
|
||||
@@ -7,11 +7,22 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
@@ -20,6 +31,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -63,7 +77,7 @@ jobs:
|
||||
|
||||
checkDependencies:
|
||||
name: Search for invalid dependencies
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
@@ -87,7 +101,7 @@ jobs:
|
||||
# Code checks
|
||||
konsist:
|
||||
name: Konsist tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
|
||||
@@ -128,12 +142,21 @@ jobs:
|
||||
|
||||
compose:
|
||||
name: Compose tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
# Records the container's real limits so heap settings can be read against them
|
||||
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
|
||||
- name: Report runner capacity
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
free -h; nproc; df -h .
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit visible"
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
@@ -162,7 +185,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Android lint check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
|
||||
@@ -207,7 +230,7 @@ jobs:
|
||||
|
||||
detekt:
|
||||
name: Detekt checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
|
||||
@@ -248,7 +271,7 @@ jobs:
|
||||
|
||||
ktlint:
|
||||
name: Ktlint checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
|
||||
@@ -331,6 +354,10 @@ jobs:
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# A job-level block replaces the workflow-level one, so contents:read has to be
|
||||
# repeated here or checkout fails on this private fork.
|
||||
contents: read
|
||||
actions: read # Required for zizmor to read workflow files.
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -5,12 +5,27 @@ on:
|
||||
pull_request:
|
||||
types: [ labeled ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dsonar.gradle.skipCompile=true
|
||||
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
# Kept character-identical to tests.yml so the record path and the verify path cannot drift
|
||||
# apart again. These only affect the Gradle daemon — Paparazzi renders in forked test workers
|
||||
# configured entirely by build.gradle.kts — but keeping them aligned removes a whole class of
|
||||
# "did record and verify run the same way?" questions.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
|
||||
|
||||
jobs:
|
||||
record:
|
||||
@@ -19,11 +34,14 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
|
||||
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -68,5 +86,9 @@ jobs:
|
||||
run: ./.github/workflows/scripts/recordScreenshots.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||
# Must be the `github.repository` context, not a secret. There is no
|
||||
# GITHUB_REPOSITORY secret on this fork, so `secrets.GITHUB_REPOSITORY` expands to ""
|
||||
# and overrides the built-in env var of the same name — recordScreenshots.sh then
|
||||
# exits 1 with "No repo specified" before Gradle ever runs.
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
|
||||
|
||||
@@ -7,24 +7,38 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
GROUP: ${{ format('sonar-{0}', github.ref) }}
|
||||
|
||||
jobs:
|
||||
sonar:
|
||||
name: Sonar Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ format('sonar-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -7,24 +7,53 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
# --max-workers caps how many test tasks run at once. build.gradle.kts sets
|
||||
# maxParallelForks = cores/2 and screenshot tests fork at maxHeapSize=2g, so on these
|
||||
# 8-core containers the default (workers = cores = 8) allows 8 x 4 x 2g = 64 GB of test
|
||||
# JVMs against a 48 GiB cgroup limit, with no swap — the daemon gets OOM-killed and
|
||||
# Gradle reports "the daemon has disappeared". GitHub's 4-core hosted runners reach only
|
||||
# 2 x 2 x 2g = 16 GB and additionally have the 8 GB swapfile added above.
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Runs unit tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
# Records the container's real limits so heap settings can be read against them
|
||||
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
|
||||
- name: Report runner capacity
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
free -h; nproc; df -h .
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit visible"
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -41,6 +70,9 @@ jobs:
|
||||
# Increase swapfile size to prevent screenshot tests getting terminated
|
||||
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
||||
- name: 💽 Increase swapfile size
|
||||
# /mnt/swapfile is a GitHub runner-image path and this needs passwordless sudo,
|
||||
# so it only applies to the hosted VMs.
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo swapoff -a
|
||||
sudo fallocate -l 8G /mnt/swapfile
|
||||
@@ -107,7 +139,14 @@ jobs:
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# https://github.com/codecov/codecov-action
|
||||
# This fork has no CODECOV_TOKEN, and Codecov rejects tokenless uploads from private
|
||||
# repositories ("Token required - not valid tokenless upload"). With
|
||||
# fail_ci_if_error: true that failed the whole job *after* the tests had passed, so
|
||||
# skip the upload unless a token is configured. Adding the secret re-enables it.
|
||||
- name: ☂️ Upload coverage reports to codecov
|
||||
if: env.CODECOV_TOKEN != ''
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -2,7 +2,11 @@ name: Validate Git LFS
|
||||
|
||||
on: [pull_request, merge_group]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -6,6 +6,20 @@ top section matching the released version — the heading must have the exact
|
||||
form `## Changes in <version> (<date>)` and the whole section must stay under
|
||||
500 characters. See `.claude/skills/releasing-healthchat/SKILL.md`.
|
||||
|
||||
## Changes in 26.08.0 (2026-08-06)
|
||||
|
||||
🎉 Neue Funktionen
|
||||
|
||||
- Mitgliederverzeichnis: Alle Mitglieder durchsuchen und direkt einen Chat starten.
|
||||
- Profil bearbeiten: Angaben und Profilbild direkt in der App pflegen.
|
||||
- Neue Mitglieder per E-Mail einladen.
|
||||
- Statushinweise zeigen den Stand der Kontofreigabe.
|
||||
|
||||
🙌 Verbesserungen
|
||||
|
||||
- HealthChat-Links öffnen sich neu direkt in der App statt im Browser.
|
||||
- Geteilte Profillinks verweisen neu auf HealthChat statt auf Element.
|
||||
|
||||
## Changes in 26.07.0 (2026-07-16)
|
||||
|
||||
🎉 Komplett neue App-Generation
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
Using launchMode singleTask to avoid multiple instances of the Activity
|
||||
when the app is already open. This is important for incoming share (see
|
||||
https://github.com/element-hq/element-x-android/issues/4074) and for opening
|
||||
the application from a mobile.element.io link.
|
||||
the application from a permalink.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -72,7 +72,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:host="open"
|
||||
android:scheme="elementx" />
|
||||
android:scheme="healthchat" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
OAuth redirection
|
||||
@@ -86,7 +86,9 @@
|
||||
<data android:scheme="@string/login_redirect_scheme" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Element web links
|
||||
HealthChat web links
|
||||
Note: hosts are listed one by one on purpose, a "*.healthchat.ch" wildcard would
|
||||
also intercept the homeserver and authentication domains.
|
||||
-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -95,28 +97,12 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<!-- Note: we can't use "*.element.io" here because it'll intercept the "mas.element.io" domain too. -->
|
||||
<!-- Matching asset file: https://app.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="app.element.io" />
|
||||
<!-- Matching asset file: https://develop.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="develop.element.io" />
|
||||
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="staging.element.io" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Element mobile links
|
||||
Example: https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org
|
||||
-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="mobile.element.io" />
|
||||
<data android:path="/element/" />
|
||||
<!-- HealthChat web client permalinks, example: https://app.healthchat.ch/#/room/#room:healthchat.ch -->
|
||||
<!-- Matching asset file: https://app.healthchat.ch/.well-known/assetlinks.json -->
|
||||
<data android:host="app.healthchat.ch" />
|
||||
<!-- HealthChat matrix.to deployment, example: https://to.healthchat.ch/#/#room:healthchat.ch -->
|
||||
<!-- Matching asset file: https://to.healthchat.ch/.well-known/assetlinks.json -->
|
||||
<data android:host="to.healthchat.ch" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
matrix: links
|
||||
@@ -130,7 +116,7 @@
|
||||
<data android:scheme="matrix" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
links from matrix.to website
|
||||
links from the HealthChat matrix.to website, example: healthchat://room/%23room%3Ahealthchat.ch
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -138,7 +124,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="element" />
|
||||
<data android:scheme="healthchat" />
|
||||
<data android:host="user" />
|
||||
<data android:host="room" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -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>
|
||||
@@ -10,5 +10,19 @@ package io.element.android.appconfig
|
||||
|
||||
object MatrixConfiguration {
|
||||
const val MATRIX_TO_PERMALINK_BASE_URL: String = "https://matrix.to/#/"
|
||||
|
||||
/**
|
||||
* HealthChat's own matrix.to deployment (https://github.com/Novaloop-AG/healthchat-matrix.to).
|
||||
* It uses the exact same URL format as matrix.to, so links can be translated by swapping the
|
||||
* base URL. Used both to recognise incoming links and to build links shared outside of Matrix.
|
||||
*/
|
||||
const val HEALTHCHAT_TO_PERMALINK_BASE_URL: String = "https://to.healthchat.ch/#/"
|
||||
|
||||
/**
|
||||
* Base URL of the HealthChat web client, which is what the healthchat:// custom scheme
|
||||
* handed out by [HEALTHCHAT_TO_PERMALINK_BASE_URL] points to.
|
||||
*/
|
||||
const val HEALTHCHAT_WEB_BASE_URL: String = "https://app.healthchat.ch/#/"
|
||||
|
||||
val clientPermalinkBaseUrl: String? = null
|
||||
}
|
||||
|
||||
|
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() {
|
||||
callback.navigateToBugReport()
|
||||
}
|
||||
|
||||
// HealthChat fork: open a user profile from the member directory tab.
|
||||
override fun navigateToUserProfile(userId: UserId) {
|
||||
backstack.push(NavTarget.UserProfile(userId))
|
||||
}
|
||||
}
|
||||
homeEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
|
||||
@@ -156,6 +156,17 @@ allprojects {
|
||||
if (isScreenshotTest) {
|
||||
// Increase heap size for screenshot tests
|
||||
maxHeapSize = "2g"
|
||||
// Give every screenshot test class its own JVM.
|
||||
//
|
||||
// There are 5 screenshot classes (PreviewShard1..4Test + PreviewA11yTest) and each
|
||||
// shard renders ~770 previews in a single process. Gradle reuses a forked JVM for the
|
||||
// next class once maxParallelForks is exhausted, so on a 6-core runner (forks = 3) two
|
||||
// classes run second, in a process that has already rendered a full shard — and they
|
||||
// render differently from how the goldens were recorded. That is exactly the observed
|
||||
// failure: shards 2 and 4 fail 100% while 1 and 3 pass 100%, deterministically, for
|
||||
// byte-identical states. The 8-core runner (forks = 4) reused only one JVM and was green
|
||||
// on the same tree. Restarting per class costs 5 JVM startups and no extra peak memory.
|
||||
forkEvery = 1L
|
||||
// Record all the languages?
|
||||
if (project.hasProperty("allLanguagesNoEnglish")) {
|
||||
// Do not record English language
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Element X Android deeplink
|
||||
# HealthChat Android deeplink
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
@@ -12,31 +12,45 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android.
|
||||
HealthChat Android supports deep linking to specific screens in the application. This document explains how to use deep links in HealthChat Android.
|
||||
|
||||
### Asset Links
|
||||
|
||||
The asset links file is available at https://element.io/.well-known/assetlinks.json
|
||||
Two hosts are claimed as verified Android App Links, and each one must serve a matching asset links
|
||||
file listing `ch.healthchat.element` and the SHA-256 of the Play app signing certificate:
|
||||
|
||||
| Host | Asset links file | Served from |
|
||||
|---|---|---|
|
||||
| `app.healthchat.ch` | https://app.healthchat.ch/.well-known/assetlinks.json | the `element-web` fork (`apps/web/res/.well-known/assetlinks.json`) |
|
||||
| `to.healthchat.ch` | https://to.healthchat.ch/.well-known/assetlinks.json | the `healthchat-matrix.to` fork (generated by `scripts/build.js`) |
|
||||
|
||||
### Supported links
|
||||
|
||||
Element Call link:
|
||||
> https://call.element.io/Example
|
||||
|
||||
Link to a user:
|
||||
> https://app.element.io/#/user/@alice:matrix.org
|
||||
> https://to.healthchat.ch/#/@alice:healthchat.ch
|
||||
> https://app.healthchat.ch/#/user/@alice:healthchat.ch
|
||||
|
||||
Link to a room by id or alias:
|
||||
> https://app.element.io/#/room/!roomid:matrix.org
|
||||
> https://app.element.io/#/room/#element-x-android:matrix.org
|
||||
> https://to.healthchat.ch/#/!roomid:healthchat.ch
|
||||
> https://app.healthchat.ch/#/room/#healthchat:healthchat.ch
|
||||
|
||||
Link to a room with a specific event:
|
||||
> https://app.element.io/#/room/!roomid:matrix.org/$eventid
|
||||
> https://app.healthchat.ch/#/room/!roomid:healthchat.ch/$eventid
|
||||
|
||||
Note that it will also work with other domain such as:
|
||||
> https://mobile.element.io
|
||||
> https://develop.element.io
|
||||
> https://staging.element.io
|
||||
The HealthChat matrix.to deployment hands out a custom scheme to Android, which is registered too:
|
||||
> healthchat://room/%23healthchat%3Ahealthchat.ch
|
||||
> healthchat://user/%40alice%3Ahealthchat.ch
|
||||
|
||||
Standard Matrix URIs also work:
|
||||
> matrix:r/healthchat:healthchat.ch
|
||||
|
||||
Only the two hosts above are declared in the manifest, so only those open the app from a browser.
|
||||
`DefaultMatrixToConverter` is host-agnostic though: any URL using the `/#/room/`, `/#/user/` or
|
||||
`/#/group/` path, as well as plain matrix.to links, are still resolved once they reach the app
|
||||
(for instance when tapped inside a message).
|
||||
|
||||
Finally, notifications use an internal deep link that is not browsable:
|
||||
> healthchat://open/{sessionId}/{roomId}/{eventId}
|
||||
|
||||
## Developer tools
|
||||
|
||||
@@ -44,28 +58,34 @@ Using an Android 12 or higher emulator
|
||||
|
||||
Ensure links verification is enabled
|
||||
```bash
|
||||
adb shell am compat enable 175408749 io.element.android.x.debug
|
||||
adb shell am compat enable 175408749 ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
Reset link verifications for the given package id
|
||||
```bash
|
||||
adb shell pm set-app-links --package io.element.android.x.debug 0 all
|
||||
adb shell pm set-app-links --package ch.healthchat.element.debug 0 all
|
||||
```
|
||||
|
||||
Force the package id links to be verified
|
||||
```bash
|
||||
adb shell pm verify-app-links --re-verify io.element.android.x.debug
|
||||
adb shell pm verify-app-links --re-verify ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
Print the link verification of the package id
|
||||
```bash
|
||||
adb shell pm get-app-links io.element.android.x.debug
|
||||
adb shell pm get-app-links ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
```
|
||||
io.element.android.x.debug:
|
||||
ch.healthchat.element.debug:
|
||||
ID: e2ece472-c266-4bf0-829c-be79959a6270
|
||||
Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E]
|
||||
Domain verification state:
|
||||
*.element.io: 1024
|
||||
app.healthchat.ch: verified
|
||||
to.healthchat.ch: verified
|
||||
```
|
||||
|
||||
Verification only succeeds if the signature above is listed in the asset links files. Release builds
|
||||
are signed by Google Play, so use the app signing certificate SHA-256 from the Play Console. For a
|
||||
debug build, add the fingerprint of `app/signature/debug.keystore` (alias `androiddebugkey`) to both
|
||||
files, or test with `healthchat://` links instead, which need no verification.
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||