Merge branch 'develop' into feature/bma/documentation
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
86
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
86
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Bug report for the Element X Android app
|
||||
description: Report any issues that you have found with the Element X app. Please [check open issues](https://github.com/vector-im/element-x-android/issues) first, in case it has already been reported.
|
||||
labels: [T-Defect]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
Please report security issues by email to security@matrix.org
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please attach screenshots, videos or logs if you can.
|
||||
placeholder: Tell us what you see!
|
||||
value: |
|
||||
1. Where are you starting? What can you see?
|
||||
2. What do you click?
|
||||
3. More steps…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: result
|
||||
attributes:
|
||||
label: Outcome
|
||||
placeholder: Tell us what went wrong
|
||||
value: |
|
||||
#### What did you expect?
|
||||
|
||||
#### What happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Your phone model
|
||||
placeholder: e.g. Samsung S6
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system version
|
||||
placeholder: e.g. Android 10.0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Application version and app store
|
||||
description: You can find the version information in Settings -> Help & About.
|
||||
placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: homeserver
|
||||
attributes:
|
||||
label: Homeserver
|
||||
description: |
|
||||
Which server is your account registered on? If it is a local or non-public homeserver, please tell us what is the homeserver implementation (ex: Synapse/Dendrite/etc.) and the version.
|
||||
placeholder: e.g. matrix.org or Synapse 1.50.0rc1
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: rageshake
|
||||
attributes:
|
||||
label: Will you send logs?
|
||||
description: |
|
||||
Did you know that you can shake your phone to submit logs for this issue? Trigger the defect, then shake your phone and you will see a popup asking if you would like to open the bug report screen. Click YES, and describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: pr
|
||||
attributes:
|
||||
label: Are you willing to provide a PR?
|
||||
description: |
|
||||
Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
47
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Enhancement request
|
||||
description: Do you have a suggestion or feature request?
|
||||
labels: [T-Enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/vector-im/element-meta/discussions/new?category=ideas).
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
label: Your use case
|
||||
description: Please feel welcome to include screenshots or mock ups.
|
||||
placeholder: Tell us what you would like to do!
|
||||
value: |
|
||||
#### What would you like to do?
|
||||
|
||||
#### Why would you like to do it?
|
||||
|
||||
#### How would you like to achieve it?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: Have you considered any alternatives?
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: Is there anything else you'd like to add?
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: pr
|
||||
attributes:
|
||||
label: Are you willing to provide a PR?
|
||||
description: |
|
||||
Don't worry, it's still OK to answer 'No' :).
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
57
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
57
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Feature
|
||||
- [ ] Bugfix
|
||||
- [ ] Technical
|
||||
- [ ] Other :
|
||||
|
||||
## Content
|
||||
|
||||
<!-- Describe shortly what has been changed -->
|
||||
|
||||
## Motivation and context
|
||||
|
||||
<!-- Provide link to the corresponding issue if applicable or explain the context -->
|
||||
|
||||
## Screenshots / GIFs
|
||||
|
||||
<!-- Only if UI have been changed
|
||||
You can use a table like this to show screenshots comparison.
|
||||
Uncomment this markdown table below and edit the last line `|||`:
|
||||
|copy screenshot of before here|copy screenshot of after here|
|
||||
-->
|
||||
|
||||
<!--
|
||||
|Before|After|
|
||||
|-|-|
|
||||
|||
|
||||
-->
|
||||
|
||||
## Tests
|
||||
|
||||
<!-- Explain how you tested your development -->
|
||||
|
||||
- Step 1
|
||||
- Step 2
|
||||
- Step ...
|
||||
|
||||
## Tested devices
|
||||
|
||||
- [ ] Physical
|
||||
- [ ] Emulator
|
||||
- OS version(s):
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
|
||||
|
||||
- [ ] Changes has been tested on an Android device or Android emulator with API 21
|
||||
- [ ] UI change has been tested on both light and dark themes
|
||||
- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#accessibility
|
||||
- [ ] Pull request is based on the develop branch
|
||||
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog
|
||||
- [ ] Pull request includes screenshots or videos if containing UI changes
|
||||
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
|
||||
- [ ] You've made a self review of your PR
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -31,3 +31,7 @@ jobs:
|
||||
name: elementx-debug
|
||||
path: |
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
- name: Compile release sources
|
||||
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Compile nighlty sources
|
||||
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@11.2.1
|
||||
uses: danger/danger-js@11.2.2
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
|
||||
18
.github/workflows/gradle-wrapper-update.yml
vendored
Normal file
18
.github/workflows/gradle-wrapper-update.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Update Gradle Wrapper
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
update-gradle-wrapper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Update Gradle Wrapper
|
||||
uses: gradle-update/update-gradle-wrapper-action@v1
|
||||
# Skip in forks
|
||||
if: github.repository == 'vector-im/element-x-android'
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target-branch: develop
|
||||
14
.github/workflows/gradle-wrapper-validation.yml
vendored
Normal file
14
.github/workflows/gradle-wrapper-validation.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: "Validate Gradle Wrapper"
|
||||
on:
|
||||
pull_request: { }
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
validation:
|
||||
name: "Validation"
|
||||
runs-on: ubuntu-latest
|
||||
# No concurrency required, this is a prerequisite to other actions and should run every time.
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
36
.github/workflows/maestro.yml
vendored
Normal file
36
.github/workflows/maestro.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Maestro
|
||||
|
||||
on:
|
||||
pull_request: { }
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||
|
||||
jobs:
|
||||
maestro-cloud:
|
||||
name: Maestro test suite
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref != 'refs/heads/main'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Allow all jobs on develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/develop' && format('maestro-develop-{0}', github.sha) || format('maestro-debug-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
- uses: mobile-dev-inc/action-maestro-cloud@v1.1.1
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
app-file: app/build/outputs/apk/debug/app-debug.apk
|
||||
env: |
|
||||
USERNAME=maestroelement
|
||||
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
|
||||
ROOM_NAME=MyRoom
|
||||
APP_ID=io.element.android.x.debug
|
||||
9
.github/workflows/nightly.yml
vendored
9
.github/workflows/nightly.yml
vendored
@@ -15,6 +15,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install towncrier
|
||||
run: |
|
||||
python3 -m pip install towncrier
|
||||
- name: Prepare changelog file
|
||||
run: |
|
||||
mv towncrier.toml towncrier.toml.bak
|
||||
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
|
||||
rm towncrier.toml.bak
|
||||
yes n | towncrier build --version nightly
|
||||
- name: Build and upload Nightly APK
|
||||
run: |
|
||||
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@11.2.1
|
||||
uses: danger/danger-js@11.2.2
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
|
||||
25
.github/workflows/sync-from-external-sources.yml
vendored
Normal file
25
.github/workflows/sync-from-external-sources.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Sync Data From External Sources
|
||||
on:
|
||||
schedule:
|
||||
# Every nights at 6
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
jobs:
|
||||
sync-strings:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: github.repository == 'vector-im/element-x-android'
|
||||
# No concurrency required, runs every time on a schedule.
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run local script
|
||||
run: ./tools/strings/importStringsFromElement.sh
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
commit-message: Import strings from Element Android
|
||||
title: Sync strings
|
||||
body: |
|
||||
- Update strings from Element Android
|
||||
branch: sync-strings
|
||||
base: develop
|
||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -23,3 +23,12 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run tests
|
||||
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: Archive test results on error
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: screenshot-results
|
||||
path: |
|
||||
**/out/failures/
|
||||
**/build/reports/tests/*UnitTest/
|
||||
|
||||
13
.github/workflows/triage-labelled.yml
vendored
13
.github/workflows/triage-labelled.yml
vendored
@@ -2,7 +2,7 @@ name: Move labelled issues to correct boards and columns
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ labeled ]
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
move_element_x_issues:
|
||||
@@ -10,14 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: >
|
||||
github.repository == 'vector-im/element-x-android' &&
|
||||
(contains(github.event.issue.labels.*.name, 'Z-Setup') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Alpha') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Beta') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-BBQ-Release') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Alpha') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Beta') ||
|
||||
contains(github.event.issue.labels.*.name, 'Z-Banquet-Release'))
|
||||
github.repository == 'vector-im/element-x-android'
|
||||
steps:
|
||||
- uses: octokit/graphql-action@v2.x
|
||||
with:
|
||||
@@ -33,5 +26,5 @@ jobs:
|
||||
projectid: ${{ env.PROJECT_ID }}
|
||||
contentid: ${{ github.event.issue.node_id }}
|
||||
env:
|
||||
PROJECT_ID: "PN_kwDOAM0swc4ABTXY"
|
||||
PROJECT_ID: "PVT_kwDOAM0swc4ABTXY"
|
||||
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
15
.github/workflows/validate-lfs.yml
vendored
Normal file
15
.github/workflows/validate-lfs.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Validate Git LFS
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
lfs: 'true'
|
||||
|
||||
- run: |
|
||||
./tools/git/validate_lfs.sh
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -83,3 +83,5 @@ lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
|
||||
/tmp
|
||||
|
||||
71
.maestro/README.md
Normal file
71
.maestro/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Maestro
|
||||
|
||||
Maestro is a framework that we are using to test navigation across the application.
|
||||
To setup, please refer at [https://maestro.mobile.dev](https://maestro.mobile.dev)
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
* [Run test](#run-test)
|
||||
* [Output](#output)
|
||||
* [Write test](#write-test)
|
||||
* [CI](#ci)
|
||||
* [iOS](#ios)
|
||||
* [Future](#future)
|
||||
|
||||
<!--- END -->
|
||||
|
||||
## Run test
|
||||
|
||||
From root dir of the project
|
||||
|
||||
*Note: Since ElementX does not allow account creation nor room creation, we have to use an existing account with an existing room to run maestro test suite. So to run locally, please replace `user` and `123` with your test matrix.org account credentials, and `my room` with one of a room this account has join. Note that the test will send messages to this room.*
|
||||
|
||||
```shell
|
||||
maestro test \
|
||||
-e APP_ID=io.element.android.x.debug \
|
||||
-e USERNAME=user \
|
||||
-e PASSWORD=123 \
|
||||
-e ROOM_NAME="my room" \
|
||||
.maestro/allTest.yaml
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Test result will be printed on the console, and screenshots will be generated at `./build/maestro`
|
||||
|
||||
## Write test
|
||||
|
||||
Tests are yaml files. Generally each yaml file should leave the app in the same screen than at the beginning.
|
||||
|
||||
Start the ElementX app and run this command to help writing test.
|
||||
|
||||
```shell
|
||||
maestro studio
|
||||
```
|
||||
|
||||
Note that sometimes, this prevent running the test. So kill the `maestro studio` process to be able to run the test again.
|
||||
|
||||
Also, if updating the application code, do not forget to deploy again the application before running the maestro tests.
|
||||
|
||||
## CI
|
||||
|
||||
The CI is running maestro using the workflow `.github/worflow/maestro.yaml` and [maestro cloud](https://cloud.mobile.dev/). For now we are limited to 100 runs a month.
|
||||
Some GitHub secrets are used to be able to do that: `MAESTRO_CLOUD_API_KEY`, for now api key from `benoitm@element.io` maestro cloud account, and `MATRIX_MAESTRO_ACCOUNT_PASSWORD` which is the password of the account `@maestroelement:matrix.org`. This account contains a room `MyRoom` to be able to run the maestro test suite.
|
||||
|
||||
## iOS
|
||||
|
||||
Need to install `idb-companion` first
|
||||
|
||||
```shell
|
||||
brew install idb-companion
|
||||
```
|
||||
|
||||
Also:
|
||||
https://github.com/mobile-dev-inc/maestro/issues/146
|
||||
https://github.com/mobile-dev-inc/maestro/issues/107
|
||||
So you have to change your input keyboard to QWERTY for it to work properly.
|
||||
|
||||
## Future
|
||||
|
||||
- run on Element X iOS. This is already working but it need some change on the test to make it works. Could pass a PLATFORM parameter to have unique test and use conditional test.
|
||||
- run specific test on both iOS and Android devices to make them communicate together. Could be possible to test room invite and join, verification, call, etc. To be done when Element X will be able to create account and create room. A main script would be able to detect the Android device and the iOS device, and run several maestro tests sequentially, using `--device` parameter to perform a global test.
|
||||
7
.maestro/allTests.yaml
Normal file
7
.maestro/allTests.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- runFlow: tests/init.yaml
|
||||
- runFlow: tests/account/login.yaml
|
||||
- runFlow: tests/settings/settings.yaml
|
||||
- runFlow: tests/roomList/roomList.yaml
|
||||
- runFlow: tests/account/logout.yaml
|
||||
5
.maestro/tests/account/changeServer.yaml
Normal file
5
.maestro/tests/account/changeServer.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "Change"
|
||||
- takeScreenshot: build/maestro/200-ChangeServer
|
||||
- tapOn: "Continue"
|
||||
21
.maestro/tests/account/login.yaml
Normal file
21
.maestro/tests/account/login.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "Get started"
|
||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||
- takeScreenshot: build/maestro/100-SignIn
|
||||
- runFlow: changeServer.yaml
|
||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||
- tapOn: "Username or email"
|
||||
# ios
|
||||
# - tapOn:
|
||||
# id: "usernameTextField"
|
||||
# index: 0
|
||||
- inputText: ${USERNAME}
|
||||
- tapOn: "Password"
|
||||
# iOS
|
||||
#- tapOn:
|
||||
# id: "passwordTextField"
|
||||
# index: 0
|
||||
- inputText: ${PASSWORD}
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
12
.maestro/tests/account/logout.yaml
Normal file
12
.maestro/tests/account/logout.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "Settings"
|
||||
- tapOn: "Sign out"
|
||||
- takeScreenshot: build/maestro/900-SignOutDialg
|
||||
# Ensure cancel cancels
|
||||
- tapOn: "Cancel"
|
||||
- tapOn: "Sign out"
|
||||
- tapOn:
|
||||
text: "Sign out"
|
||||
index: 1
|
||||
- runFlow: ../assertions/assertInitDisplayed.yaml
|
||||
5
.maestro/tests/assertions/assertHomeDisplayed.yaml
Normal file
5
.maestro/tests/assertions/assertHomeDisplayed.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "All Chats"
|
||||
timeout: 10_000
|
||||
5
.maestro/tests/assertions/assertInitDisplayed.yaml
Normal file
5
.maestro/tests/assertions/assertInitDisplayed.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Own your conversations."
|
||||
timeout: 10_000
|
||||
5
.maestro/tests/assertions/assertLoginDisplayed.yaml
Normal file
5
.maestro/tests/assertions/assertLoginDisplayed.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back!"
|
||||
timeout: 10_000
|
||||
8
.maestro/tests/init.yaml
Normal file
8
.maestro/tests/init.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- clearState
|
||||
- launchApp:
|
||||
clearKeychain: true
|
||||
- tapOn: "Close showkase button"
|
||||
- runFlow: ./assertions/assertInitDisplayed.yaml
|
||||
- takeScreenshot: build/maestro/000-FirstScreen
|
||||
6
.maestro/tests/roomList/roomList.yaml
Normal file
6
.maestro/tests/roomList/roomList.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- takeScreenshot: build/maestro/300-RoomList
|
||||
- runFlow: searchRoomList.yaml
|
||||
- runFlow: timeline/timeline.yaml
|
||||
|
||||
15
.maestro/tests/roomList/searchRoomList.yaml
Normal file
15
.maestro/tests/roomList/searchRoomList.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "search"
|
||||
- inputText: ${ROOM_NAME.substring(0, 3)}
|
||||
- takeScreenshot: build/maestro/400-SearchRoom
|
||||
- tapOn: ${ROOM_NAME}
|
||||
# Close keyboard
|
||||
- hideKeyboard
|
||||
# Back from timeline
|
||||
- back
|
||||
# Close keyboard
|
||||
- hideKeyboard
|
||||
# Back from search
|
||||
- back
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
14
.maestro/tests/roomList/timeline/timeline.yaml
Normal file
14
.maestro/tests/roomList/timeline/timeline.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
# This is the name of one room
|
||||
# TODO Create a room on a new account
|
||||
- tapOn: ${ROOM_NAME}
|
||||
- takeScreenshot: build/maestro/500-Timeline
|
||||
- tapOn: "Message…"
|
||||
- inputText: "Hello world!"
|
||||
- tapOn: "Toggle full screen mode"
|
||||
- tapOn: "Toggle full screen mode"
|
||||
- tapOn: "Send"
|
||||
- hideKeyboard
|
||||
- back
|
||||
- runFlow: ../../assertions/assertHomeDisplayed.yaml
|
||||
12
.maestro/tests/settings/settings.yaml
Normal file
12
.maestro/tests/settings/settings.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- tapOn: "Settings"
|
||||
- assertVisible: "Rage shake to report bug"
|
||||
- takeScreenshot: build/maestro/600-Settings
|
||||
- tapOn:
|
||||
text: "Report bug"
|
||||
index: 1
|
||||
- assertVisible: "Describe your problem here"
|
||||
- back
|
||||
- back
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
0
CHANGES.md
Normal file
0
CHANGES.md
Normal file
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -19,15 +19,21 @@ package io.element.android.x.anvilannotations
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Adds view model to the specified component graph.
|
||||
* Equivalent to the following declaration in a dagger module:
|
||||
* Adds Node to the specified component graph.
|
||||
* Equivalent to the following declaration:
|
||||
*
|
||||
* @Binds
|
||||
* @IntoMap
|
||||
* @ViewModelKey(YourViewModel::class)
|
||||
* public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *>
|
||||
* @Module
|
||||
* @ContributesTo(Scope::class)
|
||||
* abstract class YourNodeModule {
|
||||
|
||||
* @Binds
|
||||
* @IntoMap
|
||||
* @NodeKey(YourNode::class)
|
||||
* abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
|
||||
*}
|
||||
|
||||
*/
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class ContributesViewModel(
|
||||
annotation class ContributesNode(
|
||||
val scope: KClass<*>,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -46,32 +46,32 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.multibindings.IntoMap
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import java.io.File
|
||||
import io.element.android.x.anvilannotations.ContributesNode
|
||||
import org.jetbrains.kotlin.descriptors.ModuleDescriptor
|
||||
import org.jetbrains.kotlin.name.FqName
|
||||
import org.jetbrains.kotlin.psi.KtFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* This is an anvil plugin that allows ViewModels to use [ContributesViewModel] alone and let this plugin automatically
|
||||
* This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically
|
||||
* handle the rest of the Dagger wiring required for constructor injection.
|
||||
*/
|
||||
@AutoService(CodeGenerator::class)
|
||||
class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||
class ContributesNodeCodeGenerator : CodeGenerator {
|
||||
|
||||
override fun isApplicable(context: AnvilContext): Boolean = true
|
||||
|
||||
override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile>): Collection<GeneratedFile> {
|
||||
return projectFiles.classAndInnerClassReferences(module)
|
||||
.filter { it.isAnnotatedWith(ContributesViewModel::class.fqName) }
|
||||
.filter { it.isAnnotatedWith(ContributesNode::class.fqName) }
|
||||
.flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun generateModule(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||
val generatedPackage = vmClass.packageFqName.toString()
|
||||
val moduleClassName = "${vmClass.shortName}_Module"
|
||||
val scope = vmClass.annotations.single { it.fqName == ContributesViewModel::class.fqName }.scope()
|
||||
private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||
val generatedPackage = nodeClass.packageFqName.toString()
|
||||
val moduleClassName = "${nodeClass.shortName}_Module"
|
||||
val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope()
|
||||
val content = FileSpec.buildFile(generatedPackage, moduleClassName) {
|
||||
addType(
|
||||
TypeSpec.classBuilder(moduleClassName)
|
||||
@@ -79,17 +79,17 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||
.addAnnotation(Module::class)
|
||||
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build())
|
||||
.addFunction(
|
||||
FunSpec.builder("bind${vmClass.shortName}Factory")
|
||||
FunSpec.builder("bind${nodeClass.shortName}Factory")
|
||||
.addModifiers(KModifier.ABSTRACT)
|
||||
.addParameter("factory", ClassName(generatedPackage, "${vmClass.shortName}_AssistedFactory"))
|
||||
.returns(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(STAR, STAR))
|
||||
.addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory"))
|
||||
.returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR))
|
||||
.addAnnotation(Binds::class)
|
||||
.addAnnotation(IntoMap::class)
|
||||
.addAnnotation(
|
||||
AnnotationSpec.Companion
|
||||
.builder(viewModelKeyFqName.asClassName(module))
|
||||
.addMember("%T::class", vmClass.asClassName())
|
||||
.build()
|
||||
AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember(
|
||||
"%T::class",
|
||||
nodeClass.asClassName()
|
||||
).build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
@@ -99,35 +99,46 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||
return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content)
|
||||
}
|
||||
|
||||
private fun generateAssistedFactory(vmClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||
val generatedPackage = vmClass.packageFqName.toString()
|
||||
val assistedFactoryClassName = "${vmClass.shortName}_AssistedFactory"
|
||||
val constructor = vmClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
|
||||
val assistedParameter = constructor?.parameters?.singleOrNull { it.isAnnotatedWith(Assisted::class.fqName) }
|
||||
if (constructor == null || assistedParameter == null) {
|
||||
private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
|
||||
val generatedPackage = nodeClass.packageFqName.toString()
|
||||
val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory"
|
||||
val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
|
||||
val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty()
|
||||
if (constructor == null || assistedParameters.size != 2) {
|
||||
throw AnvilCompilationException(
|
||||
"${vmClass.fqName} must have an @AssistedInject constructor with @Assisted initialState: S parameter",
|
||||
element = vmClass.clazz,
|
||||
"${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters",
|
||||
element = nodeClass.clazz,
|
||||
)
|
||||
}
|
||||
if (assistedParameter.name != "initialState") {
|
||||
val contextAssistedParam = assistedParameters[0]
|
||||
if (contextAssistedParam.name != "buildContext") {
|
||||
throw AnvilCompilationException(
|
||||
"${vmClass.fqName} @Assisted parameter must be named initialState",
|
||||
element = assistedParameter.parameter,
|
||||
"${nodeClass.fqName} @Assisted parameter must be named buildContext",
|
||||
element = contextAssistedParam.parameter,
|
||||
)
|
||||
}
|
||||
val vmClassName = vmClass.asClassName()
|
||||
val stateClassName = assistedParameter.type().asTypeName()
|
||||
val pluginsAssistedParam = assistedParameters[1]
|
||||
if (pluginsAssistedParam.name != "plugins") {
|
||||
throw AnvilCompilationException(
|
||||
"${nodeClass.fqName} @Assisted parameter must be named plugins",
|
||||
element = pluginsAssistedParam.parameter,
|
||||
)
|
||||
}
|
||||
|
||||
val nodeClassName = nodeClass.asClassName()
|
||||
val buildContextClassName = contextAssistedParam.type().asTypeName()
|
||||
val pluginsClassName = pluginsAssistedParam.type().asTypeName()
|
||||
val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) {
|
||||
addType(
|
||||
TypeSpec.interfaceBuilder(assistedFactoryClassName)
|
||||
.addSuperinterface(assistedViewModelFactoryFqName.asClassName(module).parameterizedBy(vmClassName, stateClassName))
|
||||
.addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName))
|
||||
.addAnnotation(AssistedFactory::class)
|
||||
.addFunction(
|
||||
FunSpec.builder("create")
|
||||
.addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT)
|
||||
.addParameter("initialState", stateClassName)
|
||||
.returns(vmClassName)
|
||||
.addParameter("buildContext", buildContextClassName)
|
||||
.addParameter("plugins", pluginsClassName)
|
||||
.returns(nodeClassName)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
@@ -137,7 +148,7 @@ class ContributesViewModelCodeGenerator : CodeGenerator {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val assistedViewModelFactoryFqName = FqName("io.element.android.x.core.di.AssistedViewModelFactory")
|
||||
private val viewModelKeyFqName = FqName("io.element.android.x.core.di.ViewModelKey")
|
||||
private val assistedNodeFactoryFqName = FqName("io.element.android.x.architecture.AssistedNodeFactory")
|
||||
private val nodeKeyFqName = FqName("io.element.android.x.architecture.NodeKey")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
|
||||
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
@@ -14,21 +16,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import extension.allFeatures
|
||||
import extension.allLibraries
|
||||
|
||||
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
|
||||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id("io.element.android-compose-application")
|
||||
alias(libs.plugins.stem)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.kapt)
|
||||
id("com.google.firebase.appdistribution") version "3.0.2"
|
||||
id("org.jetbrains.kotlinx.knit") version "0.4.0"
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.x"
|
||||
|
||||
testOptions { unitTests.isIncludeAndroidResources = true }
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.element.android.x"
|
||||
targetSdk = 33 // TODO Use Versions.targetSdk
|
||||
@@ -93,7 +102,8 @@ android {
|
||||
|
||||
firebaseAppDistribution {
|
||||
artifactType = "APK"
|
||||
// releaseNotesFile = TODO
|
||||
// This file will be generated by the GitHub action
|
||||
releaseNotesFile = "CHANGES_NIGHTLY.md"
|
||||
groups = "external-testers"
|
||||
// This should not be required, but if I do not add the appId, I get this error:
|
||||
// "App Distribution halted because it had a problem uploading the APK: [404] Requested entity was not found."
|
||||
@@ -112,7 +122,7 @@ android {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.3.2"
|
||||
kotlinCompilerExtensionVersion = "1.4.0"
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -120,6 +130,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Waiting for https://github.com/google/ksp/issues/37
|
||||
applicationVariants.all {
|
||||
kotlin.sourceSets {
|
||||
getByName(name) {
|
||||
@@ -151,36 +162,22 @@ knit {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":libraries:designsystem"))
|
||||
implementation(project(":libraries:matrix"))
|
||||
implementation(project(":libraries:matrixui"))
|
||||
implementation(project(":libraries:core"))
|
||||
implementation(project(":features:onboarding"))
|
||||
implementation(project(":features:login"))
|
||||
implementation(project(":features:logout"))
|
||||
implementation(project(":features:roomlist"))
|
||||
implementation(project(":features:messages"))
|
||||
implementation(project(":features:rageshake"))
|
||||
implementation(project(":features:preferences"))
|
||||
implementation(project(":libraries:di"))
|
||||
allLibraries()
|
||||
allFeatures()
|
||||
implementation(project(":tests:uitests"))
|
||||
implementation(project(":anvilannotations"))
|
||||
anvil(project(":anvilcodegen"))
|
||||
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.0")
|
||||
implementation(libs.compose.destinations)
|
||||
ksp(libs.compose.destinations.processor)
|
||||
// https://developer.android.com/studio/write/java8-support#library-desugaring-versions
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.2")
|
||||
implementation(libs.appyx.core)
|
||||
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(libs.androidx.lifecycle.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.startup)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.mavericks.compose)
|
||||
|
||||
implementation(libs.dagger)
|
||||
kapt(libs.dagger.compiler)
|
||||
|
||||
implementation(libs.showkase)
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,10 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ElementX"
|
||||
tools:targetApi="33">
|
||||
<!-- Note: temporary block orientation to sensorPortrait because the RichTextEditor library is crashing on configuration change -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
@@ -47,7 +46,7 @@
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="remove"/>
|
||||
tools:node="remove" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(
|
||||
ExperimentalAnimationApi::class,
|
||||
ExperimentalMaterialNavigationApi::class
|
||||
)
|
||||
|
||||
package io.element.android.x
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavHostController
|
||||
import com.airbnb.android.showkase.models.Showkase
|
||||
import com.airbnb.mvrx.compose.mavericksActivityViewModel
|
||||
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
||||
import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
|
||||
import com.ramcosta.composedestinations.spec.Route
|
||||
import io.element.android.x.core.compose.OnLifecycleEvent
|
||||
import io.element.android.x.designsystem.ElementXTheme
|
||||
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
|
||||
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
|
||||
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionScreen
|
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionScreen
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
|
||||
private const val transitionAnimationDuration = 500
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
ElementXTheme {
|
||||
MainScreen(viewModel = mavericksActivityViewModel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowkaseButton(
|
||||
isVisible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onCloseClicked: () -> Unit
|
||||
) {
|
||||
if (isVisible) {
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp, start = 16.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(text = "Showkase Browser")
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(16.dp),
|
||||
onClick = onCloseClicked,
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(viewModel: MainViewModel) {
|
||||
val startRoute = runBlocking {
|
||||
if (!viewModel.isLoggedIn()) {
|
||||
OnBoardingScreenNavigationDestination
|
||||
} else {
|
||||
viewModel.restoreSession()
|
||||
NavGraphs.root.startRoute
|
||||
}
|
||||
}
|
||||
|
||||
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
|
||||
var isBugReportVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
MainContent(
|
||||
startRoute = startRoute
|
||||
)
|
||||
ShowkaseButton(
|
||||
isVisible = isShowkaseButtonVisible,
|
||||
onCloseClicked = { isShowkaseButtonVisible = false },
|
||||
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
|
||||
)
|
||||
RageshakeDetectionScreen(
|
||||
onOpenBugReport = {
|
||||
isBugReportVisible = true
|
||||
}
|
||||
)
|
||||
CrashDetectionScreen(
|
||||
onOpenBugReport = {
|
||||
isBugReportVisible = true
|
||||
}
|
||||
)
|
||||
if (isBugReportVisible) {
|
||||
// TODO Improve the navigation, when pressing back here, it closes the app.
|
||||
BugReportScreen(
|
||||
onDone = { isBugReportVisible = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
OnLifecycleEvent { _, event ->
|
||||
Timber.v("OnLifecycleEvent: $event")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainContent(startRoute: Route) {
|
||||
val engine = rememberAnimatedNavHostEngine(
|
||||
rootDefaultAnimations = RootNavGraphDefaultAnimations(
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentScope.SlideDirection.Left,
|
||||
animationSpec = tween(transitionAnimationDuration)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentScope.SlideDirection.Left,
|
||||
animationSpec = tween(transitionAnimationDuration)
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentScope.SlideDirection.Right,
|
||||
animationSpec = tween(transitionAnimationDuration)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentScope.SlideDirection.Right,
|
||||
animationSpec = tween(transitionAnimationDuration)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
val navController = engine.rememberNavController()
|
||||
LogNavigation(navController)
|
||||
|
||||
DestinationsNavHost(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
engine = engine,
|
||||
navController = navController,
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = startRoute,
|
||||
dependenciesContainerBuilder = {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogNavigation(navController: NavHostController) {
|
||||
LaunchedEffect(key1 = navController) {
|
||||
navController.appCurrentDestinationFlow.collect {
|
||||
Timber.d("Navigating to ${it.route}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun MainContentPreview() {
|
||||
MainContent(startRoute = OnBoardingScreenNavigationDestination)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x
|
||||
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.di.AppScope
|
||||
import io.element.android.x.di.SessionComponentsOwner
|
||||
import io.element.android.x.matrix.Matrix
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MainState(val fake: Boolean = false) : MavericksState
|
||||
|
||||
@ContributesViewModel(AppScope::class)
|
||||
class MainViewModel @AssistedInject constructor(
|
||||
private val matrix: Matrix,
|
||||
private val sessionComponentsOwner: SessionComponentsOwner,
|
||||
@Assisted initialState: MainState
|
||||
) : MavericksViewModel<MainState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<MainViewModel, MainState> by daggerMavericksViewModelFactory()
|
||||
|
||||
suspend fun isLoggedIn(): Boolean {
|
||||
return matrix.isLoggedIn().first()
|
||||
}
|
||||
|
||||
fun startSyncIfLogged() {
|
||||
viewModelScope.launch {
|
||||
if (!isLoggedIn()) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSyncIfLogged() {
|
||||
viewModelScope.launch {
|
||||
if (!isLoggedIn()) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreSession() {
|
||||
val matrixClient = matrix.restoreSession()
|
||||
if (matrixClient == null) {
|
||||
throw IllegalStateException("Couldn't restore session...")
|
||||
} else {
|
||||
sessionComponentsOwner.create(matrixClient)
|
||||
matrixClient.startSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootNavGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.popUpTo
|
||||
import io.element.android.x.core.di.bindings
|
||||
import io.element.android.x.destinations.BugReportScreenNavigationDestination
|
||||
import io.element.android.x.destinations.ChangeServerScreenNavigationDestination
|
||||
import io.element.android.x.destinations.LoginScreenNavigationDestination
|
||||
import io.element.android.x.destinations.MessagesScreenNavigationDestination
|
||||
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
|
||||
import io.element.android.x.destinations.PreferencesScreenNavigationDestination
|
||||
import io.element.android.x.destinations.RoomListScreenNavigationDestination
|
||||
import io.element.android.x.di.AppBindings
|
||||
import io.element.android.x.features.login.LoginScreen
|
||||
import io.element.android.x.features.login.changeserver.ChangeServerScreen
|
||||
import io.element.android.x.features.messages.MessagesScreen
|
||||
import io.element.android.x.features.onboarding.OnBoardingScreen
|
||||
import io.element.android.x.features.preferences.PreferencesScreen
|
||||
import io.element.android.x.features.rageshake.bugreport.BugReportScreen
|
||||
import io.element.android.x.features.roomlist.RoomListScreen
|
||||
import io.element.android.x.matrix.core.RoomId
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) {
|
||||
OnBoardingScreen(
|
||||
onSignUp = {
|
||||
// TODO
|
||||
},
|
||||
onSignIn = {
|
||||
navigator.navigate(LoginScreenNavigationDestination)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
|
||||
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
|
||||
LoginScreen(
|
||||
onChangeServer = {
|
||||
navigator.navigate(ChangeServerScreenNavigationDestination)
|
||||
},
|
||||
onLoginWithSuccess = {
|
||||
sessionComponentsOwner.create(it)
|
||||
navigator.navigate(RoomListScreenNavigationDestination) {
|
||||
popUpTo(OnBoardingScreenNavigationDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// TODO Create a subgraph in Login module
|
||||
@Destination
|
||||
@Composable
|
||||
fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
|
||||
ChangeServerScreen(
|
||||
onChangeServerSuccess = {
|
||||
navigator.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@RootNavGraph(start = true)
|
||||
@Destination
|
||||
@Composable
|
||||
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
|
||||
RoomListScreen(
|
||||
onRoomClicked = { roomId: RoomId ->
|
||||
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
|
||||
},
|
||||
onOpenSettings = {
|
||||
navigator.navigate(PreferencesScreenNavigationDestination())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
|
||||
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
|
||||
}
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun BugReportScreenNavigation(navigator: DestinationsNavigator) {
|
||||
BugReportScreen(
|
||||
onDone = navigator::popBackStack
|
||||
)
|
||||
}
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
fun PreferencesScreenNavigation(navigator: DestinationsNavigator) {
|
||||
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
|
||||
PreferencesScreen(
|
||||
onBackPressed = navigator::navigateUp,
|
||||
onOpenRageShake = {
|
||||
navigator.navigate(BugReportScreenNavigationDestination)
|
||||
},
|
||||
onSuccessLogout = {
|
||||
sessionComponentsOwner.releaseActiveSession()
|
||||
navigator.navigate(OnBoardingScreenNavigationDestination) {
|
||||
popUpTo(RoomListScreenNavigationDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.di
|
||||
|
||||
import android.content.Context
|
||||
import io.element.android.x.core.di.bindings
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
|
||||
private val sessionComponents = ConcurrentHashMap<String, SessionComponent>()
|
||||
var activeSessionComponent: SessionComponent? = null
|
||||
private set
|
||||
|
||||
fun setActive(sessionId: String) {
|
||||
val sessionComponent = sessionComponents[sessionId]
|
||||
if (activeSessionComponent != sessionComponent) {
|
||||
activeSessionComponent = sessionComponent
|
||||
}
|
||||
}
|
||||
|
||||
fun create(matrixClient: MatrixClient) {
|
||||
val sessionId = matrixClient.sessionId
|
||||
val sessionComponent =
|
||||
context.bindings<SessionComponent.ParentBindings>().sessionComponentBuilder()
|
||||
.client(matrixClient).build()
|
||||
sessionComponents[sessionId] = sessionComponent
|
||||
setActive(sessionId)
|
||||
}
|
||||
|
||||
fun releaseActiveSession() {
|
||||
activeSessionComponent?.also {
|
||||
release(it.matrixClient().sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun release(sessionId: String) {
|
||||
val sessionComponent = sessionComponents.remove(sessionId)
|
||||
if (activeSessionComponent == sessionComponent) {
|
||||
activeSessionComponent = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.initializer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import io.element.android.x.core.di.bindings
|
||||
import io.element.android.x.di.AppBindings
|
||||
|
||||
class CoilInitializer : Initializer<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
Coil.setImageLoader(ElementImageLoaderFactory(context))
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
||||
private class ElementImageLoaderFactory(
|
||||
private val context: Context
|
||||
) : ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader
|
||||
.Builder(context)
|
||||
.components {
|
||||
val appBindings = context.bindings<AppBindings>()
|
||||
val matrixUi = appBindings.matrixUi()
|
||||
val matrixClientProvider = {
|
||||
appBindings
|
||||
.sessionComponentsOwner().activeSessionComponent?.matrixClient()
|
||||
}
|
||||
matrixUi.registerCoilComponents(this, matrixClientProvider)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -18,36 +18,27 @@ package io.element.android.x
|
||||
|
||||
import android.app.Application
|
||||
import androidx.startup.AppInitializer
|
||||
import io.element.android.x.core.di.DaggerComponentOwner
|
||||
import io.element.android.x.core.di.bindings
|
||||
import io.element.android.x.di.AppBindings
|
||||
import io.element.android.x.di.DaggerComponentOwner
|
||||
import io.element.android.x.di.AppComponent
|
||||
import io.element.android.x.di.DaggerAppComponent
|
||||
import io.element.android.x.di.SessionComponentsOwner
|
||||
import io.element.android.x.initializer.CoilInitializer
|
||||
import io.element.android.x.initializer.CrashInitializer
|
||||
import io.element.android.x.initializer.MatrixInitializer
|
||||
import io.element.android.x.initializer.MavericksInitializer
|
||||
import io.element.android.x.initializer.TimberInitializer
|
||||
|
||||
class ElementXApplication : Application(), DaggerComponentOwner {
|
||||
|
||||
private lateinit var appComponent: AppComponent
|
||||
private var sessionComponentsOwner: SessionComponentsOwner? = null
|
||||
|
||||
override val daggerComponent: Any
|
||||
get() = listOfNotNull(sessionComponentsOwner?.activeSessionComponent, appComponent)
|
||||
get() = appComponent
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appComponent = DaggerAppComponent.factory().create(applicationContext)
|
||||
sessionComponentsOwner = bindings<AppBindings>().sessionComponentsOwner()
|
||||
AppInitializer.getInstance(this).apply {
|
||||
initializeComponent(CrashInitializer::class.java)
|
||||
initializeComponent(TimberInitializer::class.java)
|
||||
initializeComponent(MatrixInitializer::class.java)
|
||||
initializeComponent(CoilInitializer::class.java)
|
||||
initializeComponent(MavericksInitializer::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/src/main/kotlin/io/element/android/x/MainActivity.kt
Normal file
58
app/src/main/kotlin/io/element/android/x/MainActivity.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.bumble.appyx.core.integration.NodeHost
|
||||
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||
import io.element.android.x.architecture.bindings
|
||||
import io.element.android.x.di.DaggerComponentOwner
|
||||
import io.element.android.x.designsystem.ElementXTheme
|
||||
import io.element.android.x.di.AppBindings
|
||||
import io.element.android.x.node.RootFlowNode
|
||||
|
||||
class MainActivity : NodeComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val appBindings = bindings<AppBindings>()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
ElementXTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||
RootFlowNode(
|
||||
buildContext = it,
|
||||
appComponentOwner = applicationContext as DaggerComponentOwner,
|
||||
authenticationService = appBindings.authenticationService(),
|
||||
rootPresenter = appBindings.rootPresenter()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.component
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ShowkaseButton(
|
||||
isVisible: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onCloseClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (isVisible) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.padding(top = 32.dp, start = 16.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(text = "Showkase Browser")
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(16.dp),
|
||||
onClick = onCloseClicked,
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close showkase button")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,13 @@
|
||||
package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.x.matrix.Matrix
|
||||
import io.element.android.x.matrix.ui.MatrixUi
|
||||
import io.element.android.x.matrix.auth.MatrixAuthenticationService
|
||||
import io.element.android.x.root.RootPresenter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface AppBindings {
|
||||
fun coroutineScope(): CoroutineScope
|
||||
fun matrix(): Matrix
|
||||
fun matrixUi(): MatrixUi
|
||||
fun sessionComponentsOwner(): SessionComponentsOwner
|
||||
fun rootPresenter(): RootPresenter
|
||||
fun authenticationService(): MatrixAuthenticationService
|
||||
}
|
||||
@@ -20,11 +20,11 @@ import android.content.Context
|
||||
import com.squareup.anvil.annotations.MergeComponent
|
||||
import dagger.BindsInstance
|
||||
import dagger.Component
|
||||
import io.element.android.x.core.di.DaggerMavericksBindings
|
||||
import io.element.android.x.architecture.NodeFactoriesBindings
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@MergeComponent(AppScope::class)
|
||||
interface AppComponent : DaggerMavericksBindings {
|
||||
interface AppComponent : NodeFactoriesBindings {
|
||||
|
||||
@Component.Factory
|
||||
interface Factory {
|
||||
58
app/src/main/kotlin/io/element/android/x/di/AppModule.kt
Normal file
58
app/src/main/kotlin/io/element/android/x/di/AppModule.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.di
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.x.core.coroutine.CoroutineDispatchers
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.plus
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Module
|
||||
@ContributesTo(AppScope::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
fun providesBaseDirectory(@ApplicationContext context: Context): File {
|
||||
return File(context.filesDir, "sessions")
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesAppCoroutineScope(): CoroutineScope {
|
||||
return MainScope() + CoroutineName("ElementX Scope")
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesCoroutineDispatchers(): CoroutineDispatchers {
|
||||
return CoroutineDispatchers(
|
||||
io = Dispatchers.IO,
|
||||
computation = Dispatchers.Default,
|
||||
main = Dispatchers.Main,
|
||||
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
)
|
||||
}
|
||||
}
|
||||
41
app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
Normal file
41
app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import com.squareup.anvil.annotations.MergeSubcomponent
|
||||
import dagger.BindsInstance
|
||||
import dagger.Subcomponent
|
||||
import io.element.android.x.architecture.NodeFactoriesBindings
|
||||
import io.element.android.x.matrix.room.MatrixRoom
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@MergeSubcomponent(RoomScope::class)
|
||||
interface RoomComponent : NodeFactoriesBindings {
|
||||
|
||||
@Subcomponent.Builder
|
||||
interface Builder {
|
||||
@BindsInstance
|
||||
fun room(room: MatrixRoom): Builder
|
||||
fun build(): RoomComponent
|
||||
}
|
||||
|
||||
@ContributesTo(SessionScope::class)
|
||||
interface ParentBindings {
|
||||
fun roomComponentBuilder(): Builder
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,12 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||
import com.squareup.anvil.annotations.MergeSubcomponent
|
||||
import dagger.BindsInstance
|
||||
import dagger.Subcomponent
|
||||
import io.element.android.x.core.di.DaggerMavericksBindings
|
||||
import io.element.android.x.architecture.NodeFactoriesBindings
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
@MergeSubcomponent(SessionScope::class)
|
||||
interface SessionComponent : DaggerMavericksBindings {
|
||||
|
||||
fun matrixClient(): MatrixClient
|
||||
interface SessionComponent : NodeFactoriesBindings, RoomComponent.ParentBindings {
|
||||
|
||||
@Subcomponent.Builder
|
||||
interface Builder {
|
||||
@@ -32,5 +32,5 @@ class MatrixInitializer : Initializer<Unit> {
|
||||
}
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.node
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import coil.Coil
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.node.node
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import io.element.android.x.architecture.bindings
|
||||
import io.element.android.x.architecture.createNode
|
||||
import io.element.android.x.di.DaggerComponentOwner
|
||||
import io.element.android.x.di.SessionComponent
|
||||
import io.element.android.x.features.preferences.PreferencesFlowNode
|
||||
import io.element.android.x.features.roomlist.RoomListNode
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import io.element.android.x.matrix.core.RoomId
|
||||
import io.element.android.x.matrix.core.SessionId
|
||||
import io.element.android.x.matrix.ui.di.MatrixUIBindings
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class LoggedInFlowNode(
|
||||
buildContext: BuildContext,
|
||||
val sessionId: SessionId,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val onOpenBugReport: () -> Unit,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.RoomList,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<LoggedInFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
), DaggerComponentOwner {
|
||||
|
||||
override val daggerComponent: Any by lazy {
|
||||
parent!!.bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(matrixClient).build()
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
matrixClient.startSync()
|
||||
},
|
||||
onDestroy = {
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private val roomListCallback = object : RoomListNode.Callback {
|
||||
override fun onRoomClicked(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
|
||||
override fun onSettingsClicked() {
|
||||
backstack.push(NavTarget.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object RoomList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Room(val roomId: RoomId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object Settings : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.RoomList -> {
|
||||
createNode<RoomListNode>(buildContext, plugins = listOf(roomListCallback))
|
||||
}
|
||||
is NavTarget.Room -> {
|
||||
val room = matrixClient.getRoom(roomId = navTarget.roomId)
|
||||
if (room == null) {
|
||||
// TODO CREATE UNKNOWN ROOM NODE
|
||||
node(buildContext) {
|
||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Unknown room with id = ${navTarget.roomId}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RoomFlowNode(buildContext, room)
|
||||
}
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
PreferencesFlowNode(buildContext, onOpenBugReport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.node
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.node.node
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import io.element.android.x.features.login.LoginFlowNode
|
||||
import io.element.android.x.features.onboarding.OnBoardingScreen
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
class NotLoggedInFlowNode(
|
||||
buildContext: BuildContext,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.OnBoarding,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<NotLoggedInFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
) {
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onCreate = { Timber.v("OnCreate") },
|
||||
onDestroy = { Timber.v("OnDestroy") }
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object OnBoarding : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object LoginFlow : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.OnBoarding -> node(buildContext) {
|
||||
OnBoardingScreen(
|
||||
onSignIn = { backstack.replace(NavTarget.LoginFlow) }
|
||||
)
|
||||
}
|
||||
NavTarget.LoginFlow -> LoginFlowNode(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.node
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import io.element.android.x.architecture.bindings
|
||||
import io.element.android.x.architecture.createNode
|
||||
import io.element.android.x.di.DaggerComponentOwner
|
||||
import io.element.android.x.di.RoomComponent
|
||||
import io.element.android.x.features.messages.MessagesNode
|
||||
import io.element.android.x.matrix.room.MatrixRoom
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
class RoomFlowNode(
|
||||
buildContext: BuildContext,
|
||||
private val room: MatrixRoom,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<RoomFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
), DaggerComponentOwner {
|
||||
|
||||
override val daggerComponent: Any by lazy {
|
||||
parent!!.bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build()
|
||||
}
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onCreate = { Timber.v("OnCreate") },
|
||||
onDestroy = { Timber.v("OnDestroy") }
|
||||
)
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Messages -> createNode<MessagesNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Messages : NavTarget
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
157
app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt
Normal file
157
app/src/main/kotlin/io/element/android/x/node/RootFlowNode.kt
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.node
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.node.node
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import io.element.android.x.architecture.createNode
|
||||
import io.element.android.x.architecture.presenterConnector
|
||||
import io.element.android.x.di.DaggerComponentOwner
|
||||
import io.element.android.x.features.rageshake.bugreport.BugReportNode
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import io.element.android.x.matrix.auth.MatrixAuthenticationService
|
||||
import io.element.android.x.matrix.core.SessionId
|
||||
import io.element.android.x.root.RootPresenter
|
||||
import io.element.android.x.root.RootView
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class RootFlowNode(
|
||||
buildContext: BuildContext,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.SplashScreen,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
private val appComponentOwner: DaggerComponentOwner,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
rootPresenter: RootPresenter
|
||||
) :
|
||||
ParentNode<RootFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext,
|
||||
),
|
||||
|
||||
DaggerComponentOwner by appComponentOwner {
|
||||
|
||||
private val matrixClientsHolder = ConcurrentHashMap<SessionId, MatrixClient>()
|
||||
private val presenterConnector = presenterConnector(rootPresenter)
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
whenChildAttached(LoggedInFlowNode::class) { _, child ->
|
||||
child.lifecycle.subscribe(
|
||||
onDestroy = { matrixClientsHolder.remove(child.sessionId) }
|
||||
)
|
||||
}
|
||||
authenticationService.isLoggedIn()
|
||||
.distinctUntilChanged()
|
||||
.onEach { isLoggedIn ->
|
||||
Timber.v("isLoggedIn=$isLoggedIn")
|
||||
if (isLoggedIn) {
|
||||
val matrixClient = authenticationService.restoreSession()
|
||||
if (matrixClient == null) {
|
||||
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||
} else {
|
||||
matrixClientsHolder[matrixClient.sessionId] = matrixClient
|
||||
backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId))
|
||||
}
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun onOpenBugReport() {
|
||||
backstack.push(NavTarget.BugReport)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state by presenterConnector.stateFlow.collectAsState()
|
||||
RootView(
|
||||
state = state,
|
||||
onOpenBugReport = this::onOpenBugReport,
|
||||
) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
|
||||
private val bugReportNodeCallback = object : BugReportNode.Callback {
|
||||
override fun onBugReportSent() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object SplashScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object NotLoggedInFlow : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object BugReport : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.LoggedInFlow -> {
|
||||
val matrixClient =
|
||||
matrixClientsHolder[navTarget.sessionId] ?: throw IllegalStateException("Makes sure to give a matrixClient with the given sessionId")
|
||||
LoggedInFlowNode(
|
||||
buildContext = buildContext,
|
||||
sessionId = navTarget.sessionId,
|
||||
matrixClient = matrixClient,
|
||||
onOpenBugReport = this::onOpenBugReport
|
||||
)
|
||||
}
|
||||
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
|
||||
NavTarget.SplashScreen -> node(buildContext) {
|
||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
NavTarget.BugReport -> createNode<BugReportNode>(buildContext, plugins = listOf(bugReportNodeCallback))
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/src/main/kotlin/io/element/android/x/root/RootEvents.kt
Normal file
21
app/src/main/kotlin/io/element/android/x/root/RootEvents.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.root
|
||||
|
||||
sealed interface RootEvents {
|
||||
object HideShowkaseButton : RootEvents
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.x.architecture.Presenter
|
||||
import io.element.android.x.features.rageshake.bugreport.BugReportPresenter
|
||||
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionPresenter
|
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionPresenter
|
||||
import javax.inject.Inject
|
||||
|
||||
class RootPresenter @Inject constructor(
|
||||
private val bugReportPresenter: BugReportPresenter,
|
||||
private val crashDetectionPresenter: CrashDetectionPresenter,
|
||||
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
||||
) : Presenter<RootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): RootState {
|
||||
val isBugReportVisible = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val isShowkaseButtonVisible = rememberSaveable {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
|
||||
val crashDetectionState = crashDetectionPresenter.present()
|
||||
val bugReportState = bugReportPresenter.present()
|
||||
|
||||
fun handleEvent(event: RootEvents) {
|
||||
when (event) {
|
||||
RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return RootState(
|
||||
isBugReportVisible = isBugReportVisible.value,
|
||||
isShowkaseButtonVisible = isShowkaseButtonVisible.value,
|
||||
rageshakeDetectionState = rageshakeDetectionState,
|
||||
crashDetectionState = crashDetectionState,
|
||||
bugReportState = bugReportState,
|
||||
eventSink = ::handleEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/src/main/kotlin/io/element/android/x/root/RootState.kt
Normal file
32
app/src/main/kotlin/io/element/android/x/root/RootState.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.root
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.x.features.rageshake.bugreport.BugReportState
|
||||
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionState
|
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionState
|
||||
|
||||
@Stable
|
||||
data class RootState(
|
||||
val isBugReportVisible: Boolean,
|
||||
val isShowkaseButtonVisible: Boolean,
|
||||
val rageshakeDetectionState: RageshakeDetectionState,
|
||||
val crashDetectionState: CrashDetectionState,
|
||||
val bugReportState: BugReportState,
|
||||
val eventSink: (RootEvents) -> Unit
|
||||
)
|
||||
70
app/src/main/kotlin/io/element/android/x/root/RootView.kt
Normal file
70
app/src/main/kotlin/io/element/android/x/root/RootView.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.root
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.element.android.x.component.ShowkaseButton
|
||||
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionEvents
|
||||
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionView
|
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionEvents
|
||||
import io.element.android.x.features.rageshake.detection.RageshakeDetectionView
|
||||
import io.element.android.x.tests.uitests.openShowkase
|
||||
|
||||
@Composable
|
||||
fun RootView(
|
||||
state: RootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenBugReport: () -> Unit = {},
|
||||
children: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
children()
|
||||
val eventSink = state.eventSink
|
||||
val context = LocalContext.current
|
||||
|
||||
fun onOpenBugReport() {
|
||||
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
|
||||
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
|
||||
onOpenBugReport.invoke()
|
||||
}
|
||||
|
||||
ShowkaseButton(
|
||||
isVisible = state.isShowkaseButtonVisible,
|
||||
onCloseClicked = { eventSink(RootEvents.HideShowkaseButton) },
|
||||
onClick = { openShowkase(context as Activity) }
|
||||
)
|
||||
RageshakeDetectionView(
|
||||
state = state.rageshakeDetectionState,
|
||||
onOpenBugReport = ::onOpenBugReport,
|
||||
)
|
||||
CrashDetectionView(
|
||||
state = state.crashDetectionState,
|
||||
onOpenBugReport = ::onOpenBugReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~ Copyright (c) 2023 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
@@ -13,7 +14,7 @@
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The https://github.com/LikeTheSalad/android-stem requires a non empty strings.xml -->
|
||||
<string name="ignored_placeholder" translatable="false" tools:ignore="UnusedResources">ignored</string>
|
||||
</resources>
|
||||
|
||||
1
changelog.d/.gitignore
vendored
Normal file
1
changelog.d/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!.gitignore
|
||||
45
docs/screenshot_testing.md
Normal file
45
docs/screenshot_testing.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Screenshot testing
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
* [Overview](#overview)
|
||||
* [Setup](#setup)
|
||||
* [Recording](#recording)
|
||||
* [Verifying](#verifying)
|
||||
* [Contributing](#contributing)
|
||||
|
||||
<!--- END -->
|
||||
|
||||
## Overview
|
||||
|
||||
- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently.
|
||||
- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase).
|
||||
- The screenshot verification occurs on every pull request as part of the `tests.yml` workflow.
|
||||
|
||||
## Setup
|
||||
|
||||
- Install Git LFS through your package manager of choice (`brew install git-lfs` | `yay -S git-lfs`).
|
||||
- Install the Git LFS hooks into the project.
|
||||
|
||||
```bash
|
||||
# with element-android as the current working directory
|
||||
git lfs install --local
|
||||
```
|
||||
|
||||
- If installed correctly, `git push` and `git pull` will now include LFS content.
|
||||
|
||||
## Recording
|
||||
|
||||
- `./gradlew recordPaparazziDebug`
|
||||
- Paparazzi will generate images in `:tests:uitests/src/test/snapshots`, which will need to be committed to the repository using Git LFS.
|
||||
|
||||
## Verifying
|
||||
|
||||
- `./gradlew verifyPaparazziDebug`
|
||||
- In the case of failure, Paparazzi will generate images in `:tests:uitests/out/failure`. The images will show the expected and actual screenshots along with a delta of the two images.
|
||||
|
||||
## Contributing
|
||||
|
||||
- Creating Previewable Composable will automatically creates new screenshot tests.
|
||||
- After creating the new test, record and commit the newly rendered screens.
|
||||
- `./tools/git/validate_lfs.sh` can be run to ensure everything is working correctly with Git LFS, the CI also runs this check.
|
||||
@@ -20,6 +20,7 @@ plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -35,10 +36,12 @@ dependencies {
|
||||
anvil(project(":anvilcodegen"))
|
||||
implementation(project(":libraries:di"))
|
||||
implementation(project(":libraries:core"))
|
||||
implementation(project(":libraries:architecture"))
|
||||
implementation(project(":libraries:matrix"))
|
||||
implementation(project(":libraries:designsystem"))
|
||||
implementation(project(":libraries:elementresources"))
|
||||
implementation(libs.mavericks.compose)
|
||||
implementation(libs.appyx.core)
|
||||
implementation(project(":libraries:ui-strings"))
|
||||
ksp(libs.showkase.processor)
|
||||
testImplementation(libs.test.junit)
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.di.AppScope
|
||||
import io.element.android.x.matrix.Matrix
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesViewModel(AppScope::class)
|
||||
class LoginViewModel @AssistedInject constructor(
|
||||
private val matrix: Matrix,
|
||||
@Assisted initialState: LoginViewState) :
|
||||
MavericksViewModel<LoginViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<LoginViewModel, LoginViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
var formState = mutableStateOf(LoginFormState.Default)
|
||||
private set
|
||||
|
||||
init {
|
||||
snapshotFlow { formState.value }
|
||||
.onEach {
|
||||
setState { copy(formState = it) }
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
val currentHomeserver = matrix.getHomeserverOrDefault()
|
||||
setState {
|
||||
copy(
|
||||
homeserver = currentHomeserver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSubmit() {
|
||||
viewModelScope.launch {
|
||||
suspend {
|
||||
val state = awaitState()
|
||||
// Ensure the server is provided to the Rust SDK
|
||||
matrix.setHomeserver(state.homeserver)
|
||||
matrix.login(state.formState.login.trim(), state.formState.password.trim()).also {
|
||||
it.startSync()
|
||||
}
|
||||
}.execute {
|
||||
copy(loggedInClient = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSetPassword(password: String) {
|
||||
formState.value = formState.value.copy(password = password)
|
||||
setState { copy(loggedInClient = Uninitialized) }
|
||||
}
|
||||
|
||||
fun onSetName(name: String) {
|
||||
formState.value = formState.value.copy(login = name)
|
||||
setState { copy(loggedInClient = Uninitialized) }
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.changeserver
|
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.di.AppScope
|
||||
import io.element.android.x.matrix.Matrix
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesViewModel(AppScope::class)
|
||||
class ChangeServerViewModel @AssistedInject constructor(
|
||||
private val matrix: Matrix,
|
||||
@Assisted initialState: ChangeServerViewState
|
||||
) :
|
||||
MavericksViewModel<ChangeServerViewState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<ChangeServerViewModel, ChangeServerViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
init {
|
||||
setState {
|
||||
copy(
|
||||
homeserver = matrix.getHomeserverOrDefault()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setServer(server: String) {
|
||||
setState {
|
||||
copy(
|
||||
homeserver = server,
|
||||
changeServerAction = Uninitialized,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setServerSubmit() {
|
||||
viewModelScope.launch {
|
||||
suspend {
|
||||
val state = awaitState()
|
||||
matrix.setHomeserver(state.homeserver)
|
||||
}.execute {
|
||||
copy(changeServerAction = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import io.element.android.x.architecture.createNode
|
||||
import io.element.android.x.features.login.changeserver.ChangeServerNode
|
||||
import io.element.android.x.features.login.root.LoginRootNode
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class LoginFlowNode(
|
||||
buildContext: BuildContext,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
) : ParentNode<LoginFlowNode.NavTarget>(
|
||||
navModel = backstack,
|
||||
buildContext = buildContext
|
||||
) {
|
||||
|
||||
private val loginRootCallback = object : LoginRootNode.Callback {
|
||||
override fun onChangeHomeServer() {
|
||||
backstack.push(NavTarget.ChangeServer)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object ChangeServer : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> createNode<LoginRootNode>(buildContext, plugins = listOf(loginRootCallback))
|
||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(navModel = backstack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.changeserver
|
||||
|
||||
sealed interface ChangeServerEvents {
|
||||
data class SetServer(val server: String) : ChangeServerEvents
|
||||
object Submit : ChangeServerEvents
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.changeserver
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesNode
|
||||
import io.element.android.x.architecture.presenterConnector
|
||||
import io.element.android.x.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ChangeServerNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: ChangeServerPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val presenterConnector = presenterConnector(presenter)
|
||||
|
||||
private fun onSuccess() {
|
||||
navigateUp()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state by presenterConnector.stateFlow.collectAsState()
|
||||
ChangeServerView(
|
||||
state = state,
|
||||
onChangeServerSuccess = this::onSuccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.changeserver
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.x.architecture.Async
|
||||
import io.element.android.x.architecture.Presenter
|
||||
import io.element.android.x.architecture.execute
|
||||
import io.element.android.x.matrix.auth.MatrixAuthenticationService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): ChangeServerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(authenticationService.getHomeserverOrDefault())
|
||||
}
|
||||
val changeServerAction: MutableState<Async<Unit>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: ChangeServerEvents) {
|
||||
when (event) {
|
||||
is ChangeServerEvents.SetServer -> homeserver.value = event.server
|
||||
ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction)
|
||||
}
|
||||
}
|
||||
|
||||
return ChangeServerState(
|
||||
homeserver = homeserver.value,
|
||||
changeServerAction = changeServerAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
authenticationService.setHomeserver(homeserver)
|
||||
}.execute(changeServerAction)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -16,14 +16,12 @@
|
||||
|
||||
package io.element.android.x.features.login.changeserver
|
||||
|
||||
import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import io.element.android.x.architecture.Async
|
||||
|
||||
data class ChangeServerViewState(
|
||||
data class ChangeServerState(
|
||||
val homeserver: String = "",
|
||||
val changeServerAction: Async<Unit> = Uninitialized,
|
||||
) : MavericksState {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Loading
|
||||
val changeServerAction: Async<Unit> = Async.Uninitialized,
|
||||
val eventSink: (ChangeServerEvents) -> Unit = {},
|
||||
) {
|
||||
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -51,42 +52,23 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.airbnb.mvrx.Fail
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.compose.collectAsState
|
||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||
import io.element.android.x.designsystem.ElementXTheme
|
||||
import io.element.android.x.architecture.Async
|
||||
import io.element.android.x.core.compose.textFieldState
|
||||
import io.element.android.x.designsystem.components.VectorIcon
|
||||
import io.element.android.x.features.login.R
|
||||
import io.element.android.x.features.login.error.changeServerError
|
||||
|
||||
@Composable
|
||||
fun ChangeServerScreen(
|
||||
viewModel: ChangeServerViewModel = mavericksViewModel(),
|
||||
onChangeServerSuccess: () -> Unit = { }
|
||||
) {
|
||||
val state: ChangeServerViewState by viewModel.collectAsState()
|
||||
ChangeServerContent(
|
||||
state = state,
|
||||
onChangeServer = viewModel::setServer,
|
||||
onChangeServerSubmit = viewModel::setServerSubmit,
|
||||
onChangeServerSuccess = onChangeServerSuccess
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChangeServerContent(
|
||||
state: ChangeServerViewState,
|
||||
fun ChangeServerView(
|
||||
state: ChangeServerState,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServer: (String) -> Unit = {},
|
||||
onChangeServerSubmit: () -> Unit = {},
|
||||
onChangeServerSuccess: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
val scrollState = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -101,7 +83,7 @@ fun ChangeServerContent(
|
||||
)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
val isError = state.changeServerAction is Fail
|
||||
val isError = state.changeServerAction is Async.Failure
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 99.dp)
|
||||
@@ -142,12 +124,16 @@ fun ChangeServerContent(
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
|
||||
OutlinedTextField(
|
||||
value = state.homeserver,
|
||||
value = homeserverFieldState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 200.dp),
|
||||
onValueChange = onChangeServer,
|
||||
onValueChange = {
|
||||
homeserverFieldState = it
|
||||
eventSink(ChangeServerEvents.SetServer(it))
|
||||
},
|
||||
label = {
|
||||
Text(text = "Server")
|
||||
},
|
||||
@@ -157,10 +143,10 @@ fun ChangeServerContent(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onChangeServerSubmit() }
|
||||
onDone = { eventSink(ChangeServerEvents.Submit) }
|
||||
)
|
||||
)
|
||||
if (state.changeServerAction is Fail) {
|
||||
if (state.changeServerAction is Async.Failure) {
|
||||
Text(
|
||||
text = changeServerError(
|
||||
state.homeserver,
|
||||
@@ -172,7 +158,7 @@ fun ChangeServerContent(
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onChangeServerSubmit,
|
||||
onClick = { eventSink(ChangeServerEvents.Submit) },
|
||||
enabled = state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -180,11 +166,11 @@ fun ChangeServerContent(
|
||||
) {
|
||||
Text(text = "Continue")
|
||||
}
|
||||
if (state.changeServerAction is Success) {
|
||||
if (state.changeServerAction is Async.Success) {
|
||||
onChangeServerSuccess()
|
||||
}
|
||||
}
|
||||
if (state.changeServerAction is Loading) {
|
||||
if (state.changeServerAction is Async.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
@@ -196,9 +182,7 @@ fun ChangeServerContent(
|
||||
@Composable
|
||||
@Preview
|
||||
fun ChangeServerContentPreview() {
|
||||
ElementXTheme {
|
||||
ChangeServerContent(
|
||||
state = ChangeServerViewState(homeserver = "matrix.org"),
|
||||
)
|
||||
}
|
||||
ChangeServerView(
|
||||
state = ChangeServerState(homeserver = "matrix.org"),
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ package io.element.android.x.features.login.error
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.x.core.uri.isValidUrl
|
||||
import io.element.android.x.element.resources.R as ElementR
|
||||
import io.element.android.x.features.login.LoginFormState
|
||||
import io.element.android.x.features.login.root.LoginFormState
|
||||
import io.element.android.x.ui.strings.R as StringR
|
||||
|
||||
@Composable
|
||||
fun loginError(
|
||||
@@ -30,7 +30,7 @@ fun loginError(
|
||||
return when {
|
||||
data.login.isEmpty() -> "Please enter a login"
|
||||
data.password.isEmpty() -> "Please enter a password"
|
||||
throwable != null -> stringResource(id = ElementR.string.auth_invalid_login_param)
|
||||
throwable != null -> stringResource(id = StringR.string.auth_invalid_login_param)
|
||||
else -> "No error provided"
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ fun changeServerError(
|
||||
): String {
|
||||
return when {
|
||||
data.isEmpty() -> "Please enter a server URL"
|
||||
!data.isValidUrl() -> stringResource(id = ElementR.string.login_error_invalid_home_server)
|
||||
!data.isValidUrl() -> stringResource(id = StringR.string.login_error_invalid_home_server)
|
||||
throwable != null -> "That server doesn’t seem right. Please check the address."
|
||||
else -> "No error provided"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.root
|
||||
|
||||
sealed interface LoginRootEvents {
|
||||
object RefreshHomeServer : LoginRootEvents
|
||||
data class SetLogin(val login: String) : LoginRootEvents
|
||||
data class SetPassword(val password: String) : LoginRootEvents
|
||||
object Submit : LoginRootEvents
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesNode
|
||||
import io.element.android.x.architecture.presenterConnector
|
||||
import io.element.android.x.core.compose.OnLifecycleEvent
|
||||
import io.element.android.x.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LoginRootPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val presenterConnector = presenterConnector(presenter)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeHomeServer()
|
||||
}
|
||||
|
||||
private fun onChangeHomeServer() {
|
||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state by presenterConnector.stateFlow.collectAsState()
|
||||
OnLifecycleEvent { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
LoginRootScreen(
|
||||
state = state,
|
||||
onChangeServer = this::onChangeHomeServer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.x.architecture.Presenter
|
||||
import io.element.android.x.matrix.auth.MatrixAuthenticationService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LoginRootState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(authenticationService.getHomeserverOrDefault())
|
||||
}
|
||||
val loggedInState: MutableState<LoggedInState> = remember {
|
||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||
}
|
||||
val formState = rememberSaveable {
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
}
|
||||
|
||||
fun handleEvents(event: LoginRootEvents) {
|
||||
when (event) {
|
||||
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
|
||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||
copy(login = event.login)
|
||||
}
|
||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||
copy(password = event.password)
|
||||
}
|
||||
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState)
|
||||
}
|
||||
}
|
||||
|
||||
return LoginRootState(
|
||||
homeserver = homeserver.value,
|
||||
loggedInState = loggedInState.value,
|
||||
formState = formState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||
loggedInState.value = LoggedInState.LoggingIn
|
||||
try {
|
||||
authenticationService.setHomeserver(homeserver)
|
||||
val sessionId = authenticationService.login(formState.login.trim(), formState.password.trim())
|
||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||
} catch (failure: Throwable) {
|
||||
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||
formState.value = updateLambda(formState.value)
|
||||
}
|
||||
|
||||
private fun refreshHomeServer(homeserver: MutableState<String>) {
|
||||
homeserver.value = authenticationService.getHomeserverOrDefault()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.x.features.login
|
||||
package io.element.android.x.features.login.root
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -42,13 +42,13 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -58,51 +58,20 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.airbnb.mvrx.Fail
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.compose.collectAsState
|
||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||
import io.element.android.x.designsystem.ElementXTheme
|
||||
import io.element.android.x.core.compose.textFieldState
|
||||
import io.element.android.x.features.login.error.loginError
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = mavericksViewModel(),
|
||||
onChangeServer: () -> Unit = { },
|
||||
onLoginWithSuccess: (MatrixClient) -> Unit = { },
|
||||
) {
|
||||
val state: LoginViewState by viewModel.collectAsState()
|
||||
val formState: LoginFormState by viewModel.formState
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
Timber.d("resume")
|
||||
viewModel.onResume()
|
||||
}
|
||||
LoginContent(
|
||||
state = state,
|
||||
formState = formState,
|
||||
onChangeServer = onChangeServer,
|
||||
onLoginChanged = viewModel::onSetName,
|
||||
onPasswordChanged = viewModel::onSetPassword,
|
||||
onSubmitClicked = viewModel::onSubmit,
|
||||
onLoginWithSuccess = onLoginWithSuccess
|
||||
)
|
||||
}
|
||||
import io.element.android.x.matrix.core.SessionId
|
||||
import io.element.android.x.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginContent(
|
||||
state: LoginViewState,
|
||||
formState: LoginFormState,
|
||||
fun LoginRootScreen(
|
||||
state: LoginRootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServer: () -> Unit = {},
|
||||
onLoginChanged: (String) -> Unit = {},
|
||||
onPasswordChanged: (String) -> Unit = {},
|
||||
onSubmitClicked: () -> Unit = {},
|
||||
onLoginWithSuccess: (MatrixClient) -> Unit = {},
|
||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
@@ -114,6 +83,9 @@ fun LoginContent(
|
||||
.imePadding()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||
var passwordFieldState by textFieldState(stateValue = state.formState.password)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(
|
||||
@@ -121,10 +93,10 @@ fun LoginContent(
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
val isError = state.loggedInClient is Fail
|
||||
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
|
||||
// Title
|
||||
Text(
|
||||
text = "Welcome back",
|
||||
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 48.dp),
|
||||
@@ -162,30 +134,36 @@ fun LoginContent(
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = formState.login,
|
||||
value = loginFieldState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp),
|
||||
label = {
|
||||
Text(text = "Email or username")
|
||||
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
|
||||
},
|
||||
onValueChange = {
|
||||
loginFieldState = it
|
||||
eventSink(LoginRootEvents.SetLogin(it))
|
||||
},
|
||||
onValueChange = onLoginChanged,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
)
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
if (state.loggedInClient is Loading) {
|
||||
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||
// Ensure password is hidden when user submits the form
|
||||
passwordVisible = false
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = formState.password,
|
||||
value = passwordFieldState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
onValueChange = onPasswordChanged,
|
||||
onValueChange = {
|
||||
passwordFieldState = it
|
||||
eventSink(LoginRootEvents.SetPassword(it))
|
||||
},
|
||||
label = {
|
||||
Text(text = "Password")
|
||||
},
|
||||
@@ -206,12 +184,12 @@ fun LoginContent(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onSubmitClicked() }
|
||||
onDone = { eventSink(LoginRootEvents.Submit) }
|
||||
),
|
||||
)
|
||||
if (state.loggedInClient is Fail) {
|
||||
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
|
||||
Text(
|
||||
text = loginError(state.formState, state.loggedInClient.error),
|
||||
text = loginError(state.formState, state.loggedInState.failure),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
@@ -220,7 +198,7 @@ fun LoginContent(
|
||||
}
|
||||
// Submit
|
||||
Button(
|
||||
onClick = onSubmitClicked,
|
||||
onClick = { eventSink(LoginRootEvents.Submit) },
|
||||
enabled = state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -228,12 +206,12 @@ fun LoginContent(
|
||||
) {
|
||||
Text(text = "Continue")
|
||||
}
|
||||
when (val loggedInClient = state.loggedInClient) {
|
||||
is Success -> onLoginWithSuccess(loggedInClient())
|
||||
when (val loggedInState = state.loggedInState) {
|
||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (state.loggedInClient is Loading) {
|
||||
if (state.loggedInState is LoggedInState.LoggingIn) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
@@ -245,12 +223,9 @@ fun LoginContent(
|
||||
@Composable
|
||||
@Preview
|
||||
fun LoginContentPreview() {
|
||||
ElementXTheme(darkTheme = false) {
|
||||
LoginContent(
|
||||
state = LoginViewState(
|
||||
homeserver = "matrix.org",
|
||||
),
|
||||
formState = LoginFormState("", "")
|
||||
)
|
||||
}
|
||||
LoginRootScreen(
|
||||
state = LoginRootState(
|
||||
homeserver = "matrix.org",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,27 +14,34 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.login
|
||||
package io.element.android.x.features.login.root
|
||||
|
||||
import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import android.os.Parcelable
|
||||
import io.element.android.x.matrix.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class LoginViewState(
|
||||
data class LoginRootState(
|
||||
val homeserver: String = "",
|
||||
val loggedInClient: Async<MatrixClient> = Uninitialized,
|
||||
val loggedInState: LoggedInState = LoggedInState.NotLoggedIn,
|
||||
val formState: LoginFormState = LoginFormState.Default,
|
||||
) : MavericksState {
|
||||
val eventSink: (LoginRootEvents) -> Unit = {}
|
||||
) {
|
||||
val submitEnabled =
|
||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInClient !is Loading
|
||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn
|
||||
}
|
||||
|
||||
sealed interface LoggedInState {
|
||||
object NotLoggedIn : LoggedInState
|
||||
object LoggingIn : LoggedInState
|
||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class LoginFormState(
|
||||
val login: String,
|
||||
val password: String
|
||||
) {
|
||||
) : Parcelable {
|
||||
|
||||
companion object {
|
||||
val Default = LoginFormState("", "")
|
||||
@@ -34,11 +34,12 @@ dependencies {
|
||||
implementation(project(":anvilannotations"))
|
||||
anvil(project(":anvilcodegen"))
|
||||
implementation(project(":libraries:di"))
|
||||
implementation(project(":libraries:architecture"))
|
||||
implementation(project(":libraries:core"))
|
||||
implementation(project(":libraries:matrix"))
|
||||
implementation(project(":libraries:designsystem"))
|
||||
implementation(project(":libraries:elementresources"))
|
||||
implementation(libs.mavericks.compose)
|
||||
implementation(project(":libraries:ui-strings"))
|
||||
ksp(libs.showkase.processor)
|
||||
testImplementation(libs.test.junit)
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.logout
|
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.di.SessionScope
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesViewModel(SessionScope::class)
|
||||
class LogoutViewModel @AssistedInject constructor(
|
||||
private val client: MatrixClient,
|
||||
@Assisted initialState: LogoutViewState
|
||||
) : MavericksViewModel<LogoutViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<LogoutViewModel, LogoutViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
suspend {
|
||||
client.logout()
|
||||
}.execute {
|
||||
copy(logoutAction = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.logout
|
||||
|
||||
sealed interface LogoutPreferenceEvents {
|
||||
object Logout : LogoutPreferenceEvents
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.logout
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.x.architecture.Async
|
||||
import io.element.android.x.architecture.Presenter
|
||||
import io.element.android.x.architecture.execute
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : Presenter<LogoutPreferenceState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LogoutPreferenceState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val logoutAction: MutableState<Async<Unit>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: LogoutPreferenceEvents) {
|
||||
when (event) {
|
||||
LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
|
||||
}
|
||||
}
|
||||
|
||||
return LogoutPreferenceState(
|
||||
logoutAction = logoutAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
matrixClient.logout()
|
||||
}.execute(logoutAction)
|
||||
}
|
||||
}
|
||||
@@ -19,33 +19,30 @@ package io.element.android.x.features.logout
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.airbnb.mvrx.Loading
|
||||
import com.airbnb.mvrx.Success
|
||||
import com.airbnb.mvrx.compose.collectAsState
|
||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||
import io.element.android.x.designsystem.ElementXTheme
|
||||
import io.element.android.x.architecture.Async
|
||||
import io.element.android.x.designsystem.components.ProgressDialog
|
||||
import io.element.android.x.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.x.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.x.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.x.element.resources.R as ElementR
|
||||
import io.element.android.x.ui.strings.R as StringR
|
||||
|
||||
@Composable
|
||||
fun LogoutPreference(
|
||||
viewModel: LogoutViewModel = mavericksViewModel(),
|
||||
onSuccessLogout: () -> Unit = { },
|
||||
fun LogoutPreferenceView(
|
||||
state: LogoutPreferenceState,
|
||||
onSuccessLogout: () -> Unit = {}
|
||||
) {
|
||||
val state: LogoutViewState by viewModel.collectAsState()
|
||||
if (state.logoutAction is Success) {
|
||||
onSuccessLogout()
|
||||
val eventSink = state.eventSink
|
||||
if (state.logoutAction is Async.Success) {
|
||||
LaunchedEffect(state.logoutAction) {
|
||||
onSuccessLogout()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val openDialog = remember { mutableStateOf(false) }
|
||||
|
||||
LogoutPreferenceContent(
|
||||
@@ -57,15 +54,15 @@ fun LogoutPreference(
|
||||
// Log out confirmation dialog
|
||||
if (openDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = ElementR.string.action_sign_out),
|
||||
content = stringResource(id = ElementR.string.action_sign_out_confirmation_simple),
|
||||
submitText = stringResource(id = ElementR.string.action_sign_out),
|
||||
title = stringResource(id = StringR.string.action_sign_out),
|
||||
content = stringResource(id = StringR.string.action_sign_out_confirmation_simple),
|
||||
submitText = stringResource(id = StringR.string.action_sign_out),
|
||||
onCancelClicked = {
|
||||
openDialog.value = false
|
||||
},
|
||||
onSubmitClicked = {
|
||||
openDialog.value = false
|
||||
viewModel.logout()
|
||||
eventSink(LogoutPreferenceEvents.Logout)
|
||||
},
|
||||
onDismiss = {
|
||||
openDialog.value = false
|
||||
@@ -73,7 +70,7 @@ fun LogoutPreference(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.logoutAction is Loading) {
|
||||
if (state.logoutAction is Async.Loading) {
|
||||
ProgressDialog(text = "Login out...")
|
||||
}
|
||||
}
|
||||
@@ -82,9 +79,9 @@ fun LogoutPreference(
|
||||
fun LogoutPreferenceContent(
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
PreferenceCategory(title = stringResource(id = ElementR.string.settings_general_title)) {
|
||||
PreferenceCategory(title = stringResource(id = StringR.string.settings_general_title)) {
|
||||
PreferenceText(
|
||||
title = stringResource(id = ElementR.string.action_sign_out),
|
||||
title = stringResource(id = StringR.string.action_sign_out),
|
||||
icon = Icons.Default.Logout,
|
||||
onClick = onClick
|
||||
)
|
||||
@@ -94,7 +91,5 @@ fun LogoutPreferenceContent(
|
||||
@Composable
|
||||
@Preview
|
||||
fun LogoutContentPreview() {
|
||||
ElementXTheme(darkTheme = false) {
|
||||
LogoutPreference()
|
||||
}
|
||||
LogoutPreferenceView(LogoutPreferenceState())
|
||||
}
|
||||
@@ -16,10 +16,9 @@
|
||||
|
||||
package io.element.android.x.features.logout
|
||||
|
||||
import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import io.element.android.x.architecture.Async
|
||||
|
||||
data class LogoutViewState(
|
||||
val logoutAction: Async<Unit> = Uninitialized,
|
||||
) : MavericksState
|
||||
data class LogoutPreferenceState(
|
||||
val logoutAction: Async<Unit> = Async.Uninitialized,
|
||||
val eventSink: (LogoutPreferenceEvents) -> Unit = {},
|
||||
)
|
||||
@@ -35,11 +35,12 @@ dependencies {
|
||||
anvil(project(":anvilcodegen"))
|
||||
implementation(project(":libraries:di"))
|
||||
implementation(project(":libraries:core"))
|
||||
implementation(project(":libraries:architecture"))
|
||||
implementation(project(":libraries:matrix"))
|
||||
implementation(project(":libraries:matrixui"))
|
||||
implementation(project(":libraries:designsystem"))
|
||||
implementation(project(":libraries:textcomposer"))
|
||||
implementation(libs.mavericks.compose)
|
||||
implementation(libs.appyx.core)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.datetime)
|
||||
implementation(libs.accompanist.flowlayout)
|
||||
|
||||
@@ -1,689 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterialApi::class,
|
||||
ExperimentalComposeUiApi::class
|
||||
)
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.End
|
||||
import androidx.compose.ui.Alignment.Companion.Start
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.airbnb.mvrx.compose.collectAsState
|
||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||
import io.element.android.x.core.compose.LogCompositions
|
||||
import io.element.android.x.core.compose.PairCombinedPreviewParameter
|
||||
import io.element.android.x.core.data.StableCharSequence
|
||||
import io.element.android.x.designsystem.components.avatar.Avatar
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.x.features.messages.components.MessageEventBubble
|
||||
import io.element.android.x.features.messages.components.MessagesReactionsView
|
||||
import io.element.android.x.features.messages.components.MessagesTimelineItemEncryptedView
|
||||
import io.element.android.x.features.messages.components.MessagesTimelineItemImageView
|
||||
import io.element.android.x.features.messages.components.MessagesTimelineItemRedactedView
|
||||
import io.element.android.x.features.messages.components.MessagesTimelineItemTextView
|
||||
import io.element.android.x.features.messages.components.MessagesTimelineItemUnknownView
|
||||
import io.element.android.x.features.messages.components.TimelineItemActionsScreen
|
||||
import io.element.android.x.features.messages.model.AggregatedReaction
|
||||
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
|
||||
import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider
|
||||
import io.element.android.x.features.messages.model.MessagesItemReactionState
|
||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||
import io.element.android.x.features.messages.model.MessagesViewState
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
|
||||
import io.element.android.x.textcomposer.MessageComposerMode
|
||||
import io.element.android.x.textcomposer.TextComposer
|
||||
import java.lang.Math.random
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun MessagesScreen(
|
||||
roomId: String,
|
||||
onBackPressed: () -> Unit,
|
||||
viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }),
|
||||
composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId })
|
||||
) {
|
||||
fun onSendMessage(textMessage: String) {
|
||||
viewModel.sendMessage(textMessage)
|
||||
composerViewModel.updateText("")
|
||||
}
|
||||
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val roomTitle by viewModel.collectAsState(MessagesViewState::roomName)
|
||||
val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar)
|
||||
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
|
||||
val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad)
|
||||
val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent)
|
||||
val composerMode by viewModel.collectAsState(MessagesViewState::composerMode)
|
||||
val highlightedEventId by viewModel.collectAsState(MessagesViewState::highlightedEventId)
|
||||
val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen)
|
||||
val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible)
|
||||
val composerText by composerViewModel.collectAsState(MessageComposerViewState::text)
|
||||
|
||||
MessagesScreenContent(
|
||||
roomTitle = roomTitle,
|
||||
roomAvatar = roomAvatar,
|
||||
timelineItems = timelineItems().orEmpty().toImmutableList(),
|
||||
hasMoreToLoad = hasMoreToLoad,
|
||||
onReachedLoadMore = viewModel::loadMore,
|
||||
onBackPressed = onBackPressed,
|
||||
onSendMessage = ::onSendMessage,
|
||||
composerFullScreen = composerFullScreen,
|
||||
onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange,
|
||||
onComposerTextChange = composerViewModel::updateText,
|
||||
composerMode = composerMode,
|
||||
highlightedEventId = highlightedEventId,
|
||||
onCloseSpecialMode = viewModel::setNormalMode,
|
||||
composerCanSendMessage = composerCanSendMessage,
|
||||
composerText = composerText,
|
||||
onClick = {
|
||||
Timber.v("onClick on timeline item: ${it.id}")
|
||||
},
|
||||
onLongClick = {
|
||||
focusManager.clearFocus(force = true)
|
||||
viewModel.computeActionsSheetState(it)
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
TimelineItemActionsScreen(
|
||||
viewModel = viewModel,
|
||||
composerViewModel = composerViewModel,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
)
|
||||
snackBarContent?.let {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
}
|
||||
viewModel.onSnackbarShown()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesScreenContent(
|
||||
roomTitle: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
||||
hasMoreToLoad: Boolean,
|
||||
onReachedLoadMore: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
composerFullScreen: Boolean,
|
||||
onComposerFullScreenChange: () -> Unit,
|
||||
onComposerTextChange: (CharSequence) -> Unit,
|
||||
composerMode: MessageComposerMode,
|
||||
highlightedEventId: String?,
|
||||
onCloseSpecialMode: () -> Unit,
|
||||
composerCanSendMessage: Boolean,
|
||||
composerText: StableCharSequence?,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
MessagesTopAppBar(
|
||||
roomTitle = roomTitle,
|
||||
roomAvatar = roomAvatar,
|
||||
onBackPressed = onBackPressed
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
MessagesContent(
|
||||
modifier = Modifier.padding(padding),
|
||||
timelineItems = timelineItems,
|
||||
hasMoreToLoad = hasMoreToLoad,
|
||||
onReachedLoadMore = onReachedLoadMore,
|
||||
onSendMessage = onSendMessage,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
highlightedEventId = highlightedEventId,
|
||||
composerMode = composerMode,
|
||||
onCloseSpecialMode = onCloseSpecialMode,
|
||||
composerFullScreen = composerFullScreen,
|
||||
onComposerFullScreenChange = onComposerFullScreenChange,
|
||||
onComposerTextChange = onComposerTextChange,
|
||||
composerCanSendMessage = composerCanSendMessage,
|
||||
composerText = composerText
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
snackbarHostState,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesContent(
|
||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
||||
hasMoreToLoad: Boolean,
|
||||
onReachedLoadMore: () -> Unit,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
composerMode: MessageComposerMode,
|
||||
highlightedEventId: String?,
|
||||
onCloseSpecialMode: () -> Unit,
|
||||
composerFullScreen: Boolean,
|
||||
onComposerFullScreenChange: () -> Unit,
|
||||
onComposerTextChange: (CharSequence) -> Unit,
|
||||
composerCanSendMessage: Boolean,
|
||||
composerText: StableCharSequence?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
if (!composerFullScreen) {
|
||||
TimelineItems(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = timelineItems,
|
||||
highlightedEventId = highlightedEventId,
|
||||
hasMoreToLoad = hasMoreToLoad,
|
||||
onReachedLoadMore = onReachedLoadMore,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
TextComposer(
|
||||
onSendMessage = onSendMessage,
|
||||
fullscreen = composerFullScreen,
|
||||
onFullscreenToggle = onComposerFullScreenChange,
|
||||
composerMode = composerMode,
|
||||
onCloseSpecialMode = onCloseSpecialMode,
|
||||
onComposerTextChange = onComposerTextChange,
|
||||
composerCanSendMessage = composerCanSendMessage,
|
||||
composerText = composerText?.charSequence?.toString(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (composerFullScreen) {
|
||||
it.weight(1f, fill = false)
|
||||
} else {
|
||||
it.wrapContentHeight(Alignment.Bottom)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesTopAppBar(
|
||||
roomTitle: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackPressed) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (roomAvatar != null) {
|
||||
Avatar(roomAvatar)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
text = roomTitle ?: "Unknown room",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimelineItems(
|
||||
lazyListState: LazyListState,
|
||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
||||
highlightedEventId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
hasMoreToLoad: Boolean = false,
|
||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
|
||||
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit = {},
|
||||
onReachedLoadMore: () -> Unit = {},
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = lazyListState,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(
|
||||
items = timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.key() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
isHighlighted = timelineItem.key() == highlightedEventId,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
}
|
||||
if (hasMoreToLoad) {
|
||||
item {
|
||||
MessagesLoadingMoreIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
MessagesScrollHelper(
|
||||
lazyListState = lazyListState,
|
||||
timelineItems = timelineItems,
|
||||
onLoadMore = onReachedLoadMore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessagesTimelineItemState.key(): String {
|
||||
return when (this) {
|
||||
is MessagesTimelineItemState.MessageEvent -> id
|
||||
is MessagesTimelineItemState.Virtual -> id
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessagesTimelineItemState.contentType(): Int {
|
||||
return when (this) {
|
||||
is MessagesTimelineItemState.MessageEvent -> 0
|
||||
is MessagesTimelineItemState.Virtual -> 1
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimelineItemRow(
|
||||
timelineItem: MessagesTimelineItemState,
|
||||
isHighlighted: Boolean,
|
||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||
) {
|
||||
when (timelineItem) {
|
||||
is MessagesTimelineItemState.Virtual -> return
|
||||
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
|
||||
messageEvent = timelineItem,
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageEventRow(
|
||||
messageEvent: MessagesTimelineItemState.MessageEvent,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
||||
Pair(Alignment.CenterEnd, End)
|
||||
} else {
|
||||
Pair(Alignment.CenterStart, Start)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = parentAlignment
|
||||
) {
|
||||
Row {
|
||||
if (!messageEvent.isMine) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
Column(horizontalAlignment = contentAlignment) {
|
||||
if (messageEvent.showSenderInformation) {
|
||||
MessageSenderInformation(
|
||||
messageEvent.safeSenderName,
|
||||
messageEvent.senderAvatar,
|
||||
Modifier.zIndex(1f)
|
||||
)
|
||||
}
|
||||
MessageEventBubble(
|
||||
groupPosition = messageEvent.groupPosition,
|
||||
isMine = messageEvent.isMine,
|
||||
interactionSource = interactionSource,
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.zIndex(-1f)
|
||||
.widthIn(max = 320.dp)
|
||||
) {
|
||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
when (messageEvent.content) {
|
||||
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
|
||||
content = messageEvent.content,
|
||||
modifier = contentModifier
|
||||
)
|
||||
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
|
||||
content = messageEvent.content,
|
||||
modifier = contentModifier
|
||||
)
|
||||
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
|
||||
content = messageEvent.content,
|
||||
interactionSource = interactionSource,
|
||||
modifier = contentModifier,
|
||||
onTextClicked = onClick,
|
||||
onTextLongClicked = onLongClick
|
||||
)
|
||||
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
|
||||
content = messageEvent.content,
|
||||
modifier = contentModifier
|
||||
)
|
||||
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
|
||||
content = messageEvent.content,
|
||||
modifier = contentModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
MessagesReactionsView(
|
||||
reactionsState = messageEvent.reactionsState,
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
|
||||
)
|
||||
}
|
||||
if (messageEvent.isMine) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (messageEvent.groupPosition.isNew()) {
|
||||
Spacer(modifier = modifier.height(8.dp))
|
||||
} else {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
senderAvatar: AvatarData?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
if (senderAvatar != null) {
|
||||
Avatar(senderAvatar)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = sender,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.MessagesScrollHelper(
|
||||
lazyListState: LazyListState,
|
||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
||||
onLoadMore: () -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
||||
|
||||
// Auto-scroll when new timeline items appear
|
||||
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
||||
if (!lazyListState.isScrollInProgress &&
|
||||
firstVisibleItemIndex < 2
|
||||
) coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle load more preloading
|
||||
val loadMore by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
lastVisibleItemIndex > (totalItemsNumber - 30)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loadMore) {
|
||||
snapshotFlow { loadMore }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
onLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to bottom button
|
||||
if (firstVisibleItemIndex > 2) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (firstVisibleItemIndex > 10) {
|
||||
lazyListState.scrollToItem(0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.size(40.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Icon(Icons.Default.ArrowDownward, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MessagesLoadingMoreIndicator() {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
|
||||
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
|
||||
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
|
||||
)
|
||||
|
||||
@Suppress("PreviewPublic")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun TimelineItemsPreview(
|
||||
@PreviewParameter(MessagesTimelineItemContentProvider::class)
|
||||
content: MessagesTimelineItemContent
|
||||
) {
|
||||
TimelineItems(
|
||||
lazyListState = LazyListState(),
|
||||
timelineItems = persistentListOf(
|
||||
// 3 items (First Middle Last) with isMine = false
|
||||
createMessageEvent(
|
||||
isMine = false,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.First
|
||||
),
|
||||
createMessageEvent(
|
||||
isMine = false,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.Middle
|
||||
),
|
||||
createMessageEvent(
|
||||
isMine = false,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.Last
|
||||
),
|
||||
// 3 items (First Middle Last) with isMine = true
|
||||
createMessageEvent(
|
||||
isMine = true,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.First
|
||||
),
|
||||
createMessageEvent(
|
||||
isMine = true,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.Middle
|
||||
),
|
||||
createMessageEvent(
|
||||
isMine = true,
|
||||
content = content,
|
||||
groupPosition = MessagesItemGroupPosition.Last
|
||||
),
|
||||
),
|
||||
highlightedEventId = null,
|
||||
hasMoreToLoad = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMessageEvent(
|
||||
isMine: Boolean,
|
||||
content: MessagesTimelineItemContent,
|
||||
groupPosition: MessagesItemGroupPosition
|
||||
): MessagesTimelineItemState {
|
||||
return MessagesTimelineItemState.MessageEvent(
|
||||
id = random().toString(),
|
||||
senderId = "senderId",
|
||||
senderAvatar = AvatarData("sender"),
|
||||
content = content,
|
||||
reactionsState = MessagesItemReactionState(
|
||||
listOf(
|
||||
AggregatedReaction("👍", "1")
|
||||
)
|
||||
),
|
||||
isMine = isMine,
|
||||
senderDisplayName = "sender",
|
||||
groupPosition = groupPosition,
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.x.di.SessionScope
|
||||
import io.element.android.x.features.messages.model.MessagesItemAction
|
||||
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
|
||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||
import io.element.android.x.features.messages.model.MessagesViewState
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import io.element.android.x.matrix.timeline.MatrixTimeline
|
||||
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
||||
import io.element.android.x.matrix.ui.MatrixItemHelper
|
||||
import io.element.android.x.textcomposer.MessageComposerMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PAGINATION_COUNT = 50
|
||||
|
||||
@ContributesViewModel(SessionScope::class)
|
||||
class MessagesViewModel @AssistedInject constructor(
|
||||
private val client: MatrixClient,
|
||||
@Assisted private val initialState: MessagesViewState
|
||||
) :
|
||||
MavericksViewModel<MessagesViewState>(initialState) {
|
||||
|
||||
companion object : MavericksViewModelFactory<MessagesViewModel, MessagesViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
private val matrixItemHelper = MatrixItemHelper(client)
|
||||
private val room = client.getRoom(initialState.roomId)!!
|
||||
private val messageTimelineItemStateFactory =
|
||||
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
|
||||
private val timeline = room.timeline()
|
||||
|
||||
private val timelineCallback = object : MatrixTimeline.Callback {
|
||||
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
|
||||
viewModelScope.launch {
|
||||
messageTimelineItemStateFactory.pushItem(timelineItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
handleInit()
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
viewModelScope.launch {
|
||||
timeline.paginateBackwards(PAGINATION_COUNT)
|
||||
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
viewModelScope.launch {
|
||||
val state = awaitState()
|
||||
// Reset composer right away
|
||||
setNormalMode()
|
||||
when (state.composerMode) {
|
||||
is MessageComposerMode.Normal -> timeline.sendMessage(text)
|
||||
is MessageComposerMode.Edit -> timeline.editMessage(
|
||||
state.composerMode.eventId,
|
||||
text
|
||||
)
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> timeline.replyMessage(
|
||||
state.composerMode.eventId,
|
||||
text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? {
|
||||
val currentState = awaitState()
|
||||
return currentState.itemActionsSheetState.invoke()?.targetItem
|
||||
}
|
||||
|
||||
fun handleItemAction(
|
||||
action: MessagesItemAction,
|
||||
targetEvent: MessagesTimelineItemState.MessageEvent
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
when (action) {
|
||||
MessagesItemAction.Copy -> notImplementedYet()
|
||||
MessagesItemAction.Forward -> notImplementedYet()
|
||||
MessagesItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
MessagesItemAction.Edit -> handleActionEdit(targetEvent)
|
||||
MessagesItemAction.Reply -> handleActionReply(targetEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setNormalMode() {
|
||||
setComposerMode(MessageComposerMode.Normal(""))
|
||||
}
|
||||
|
||||
fun onSnackbarShown() {
|
||||
setSnackbarContent(null)
|
||||
}
|
||||
|
||||
fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent?) {
|
||||
if (messagesTimelineItemState == null) {
|
||||
setState { copy(itemActionsSheetState = Uninitialized) }
|
||||
return
|
||||
}
|
||||
suspend {
|
||||
val actions =
|
||||
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
|
||||
emptyList()
|
||||
} else {
|
||||
mutableListOf(
|
||||
MessagesItemAction.Reply,
|
||||
MessagesItemAction.Forward,
|
||||
MessagesItemAction.Copy,
|
||||
).also {
|
||||
if (messagesTimelineItemState.isMine) {
|
||||
it.add(MessagesItemAction.Edit)
|
||||
it.add(MessagesItemAction.Redact)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessagesItemActionsSheetState(
|
||||
targetItem = messagesTimelineItemState,
|
||||
actions = actions
|
||||
)
|
||||
}.execute(Dispatchers.Default) {
|
||||
copy(itemActionsSheetState = it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInit() {
|
||||
timeline.initialize()
|
||||
timeline.callback = timelineCallback
|
||||
room.syncUpdateFlow()
|
||||
.onEach {
|
||||
val avatarData =
|
||||
matrixItemHelper.loadAvatarData(
|
||||
room = room,
|
||||
size = AvatarSize.SMALL
|
||||
)
|
||||
setState {
|
||||
copy(
|
||||
roomName = room.name, roomAvatar = avatarData,
|
||||
)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
|
||||
timeline
|
||||
.timelineItems()
|
||||
.onEach(messageTimelineItemStateFactory::replaceWith)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
messageTimelineItemStateFactory
|
||||
.flow()
|
||||
.execute {
|
||||
copy(timelineItems = it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSnackbarContent(message: String?) {
|
||||
setState { copy(snackbarContent = message) }
|
||||
}
|
||||
|
||||
private fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
|
||||
viewModelScope.launch {
|
||||
room.redactEvent(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) {
|
||||
setComposerMode(
|
||||
MessageComposerMode.Edit(
|
||||
targetEvent.id,
|
||||
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) {
|
||||
setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, ""))
|
||||
}
|
||||
|
||||
private fun setComposerMode(mode: MessageComposerMode) {
|
||||
setState {
|
||||
copy(
|
||||
composerMode = mode,
|
||||
highlightedEventId = mode.relatedEventId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notImplementedYet() {
|
||||
setSnackbarContent("Not implemented yet!")
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
timeline.callback = null
|
||||
timeline.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
package io.element.android.x.features.messages.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ModalBottomSheetLayout
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.airbnb.mvrx.compose.collectAsState
|
||||
import io.element.android.x.designsystem.components.VectorIcon
|
||||
import io.element.android.x.features.messages.MessagesViewModel
|
||||
import io.element.android.x.features.messages.model.MessagesItemAction
|
||||
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
|
||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||
import io.element.android.x.features.messages.model.MessagesViewState
|
||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TimelineItemActionsScreen(
|
||||
viewModel: MessagesViewModel,
|
||||
composerViewModel: MessageComposerViewModel,
|
||||
modalBottomSheetState: ModalBottomSheetState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(modalBottomSheetState) {
|
||||
snapshotFlow { modalBottomSheetState.currentValue }
|
||||
.filter { it == ModalBottomSheetValue.Hidden }
|
||||
.collect {
|
||||
viewModel.computeActionsSheetState(null)
|
||||
}
|
||||
}
|
||||
|
||||
val itemActionsSheetState by viewModel.collectAsState(MessagesViewState::itemActionsSheetState)
|
||||
|
||||
fun onItemActionClicked(
|
||||
itemAction: MessagesItemAction,
|
||||
targetItem: MessagesTimelineItemState.MessageEvent
|
||||
) {
|
||||
viewModel.handleItemAction(itemAction, targetItem)
|
||||
coroutineScope.launch {
|
||||
val targetEvent = viewModel.getTargetEvent()
|
||||
when (itemAction) {
|
||||
is MessagesItemAction.Edit -> {
|
||||
// Entering Edit mode, update the text in the composer.
|
||||
val newComposerText =
|
||||
(targetEvent?.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
|
||||
composerViewModel.updateText(newComposerText)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
modifier = modifier,
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetContent = {
|
||||
SheetContent(
|
||||
actionsSheetState = itemActionsSheetState(),
|
||||
onActionClicked = ::onItemActionClicked,
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
)
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
actionsSheetState: MessagesItemActionsSheetState?,
|
||||
modifier: Modifier = Modifier,
|
||||
onActionClicked: (MessagesItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> },
|
||||
) {
|
||||
if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) {
|
||||
// Crashes if sheetContent size is zero
|
||||
Box(modifier = modifier.size(1.dp))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
items(actionsSheetState.actions) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onActionClicked(it, actionsSheetState.targetItem)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = it.title,
|
||||
color = if (it.destructive) MaterialTheme.colors.error else Color.Unspecified,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
VectorIcon(
|
||||
resourceId = it.icon,
|
||||
tint = if (it.destructive) MaterialTheme.colors.error else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.airbnb.mvrx.Async
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import com.airbnb.mvrx.Uninitialized
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.x.textcomposer.MessageComposerMode
|
||||
|
||||
@Stable
|
||||
data class MessagesViewState(
|
||||
val roomId: String,
|
||||
val roomName: String? = null,
|
||||
val roomAvatar: AvatarData? = null,
|
||||
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
|
||||
val hasMoreToLoad: Boolean = true,
|
||||
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized,
|
||||
val snackbarContent: String? = null,
|
||||
val highlightedEventId: String? = null,
|
||||
val composerMode: MessageComposerMode = MessageComposerMode.Normal(""),
|
||||
) : MavericksState {
|
||||
|
||||
@Suppress("unused")
|
||||
constructor(roomId: String) : this(
|
||||
roomId = roomId,
|
||||
roomName = null,
|
||||
roomAvatar = null
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages.textcomposer
|
||||
|
||||
import com.airbnb.mvrx.MavericksViewModel
|
||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
||||
import io.element.android.x.core.data.StableCharSequence
|
||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
||||
import io.element.android.x.di.SessionScope
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
|
||||
@ContributesViewModel(SessionScope::class)
|
||||
class MessageComposerViewModel @AssistedInject constructor(
|
||||
private val client: MatrixClient,
|
||||
@Assisted private val initialState: MessageComposerViewState
|
||||
) : MavericksViewModel<MessageComposerViewState>(initialState) {
|
||||
|
||||
companion object :
|
||||
MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by daggerMavericksViewModelFactory()
|
||||
|
||||
fun onComposerFullScreenChange() {
|
||||
setState {
|
||||
copy(
|
||||
isFullScreen = !isFullScreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateText(newText: CharSequence) {
|
||||
setState {
|
||||
copy(
|
||||
text = StableCharSequence(newText),
|
||||
isSendButtonVisible = newText.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages.textcomposer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import io.element.android.x.core.data.StableCharSequence
|
||||
|
||||
@Stable
|
||||
data class MessageComposerViewState(
|
||||
// val roomId: String,
|
||||
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
|
||||
val isSendButtonVisible: Boolean = false,
|
||||
val rootThreadEventId: String? = null,
|
||||
val startsThread: Boolean = false,
|
||||
// val sendMode: SendMode = SendMode.Regular("", false),
|
||||
// val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle,
|
||||
// val voiceBroadcastState: VoiceBroadcastState? = null,
|
||||
val text: StableCharSequence? = null,
|
||||
val isFullScreen: Boolean = false,
|
||||
) : MavericksState
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,17 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.initializer
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import com.airbnb.mvrx.Mavericks
|
||||
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
|
||||
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||
|
||||
class MavericksInitializer : Initializer<Unit> {
|
||||
|
||||
override fun create(context: Context) {
|
||||
Mavericks.initialize(context)
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = listOf()
|
||||
sealed interface MessagesEvents {
|
||||
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.x.anvilannotations.ContributesNode
|
||||
import io.element.android.x.architecture.presenterConnector
|
||||
import io.element.android.x.di.RoomScope
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class MessagesNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenter: MessagesPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val connector = presenterConnector(presenter)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state by connector.stateFlow.collectAsState()
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackPressed = this::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.x.architecture.Presenter
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.x.features.messages.actionlist.ActionListPresenter
|
||||
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerEvents
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerPresenter
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerState
|
||||
import io.element.android.x.features.messages.timeline.TimelineEvents
|
||||
import io.element.android.x.features.messages.timeline.TimelinePresenter
|
||||
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
|
||||
import io.element.android.x.matrix.MatrixClient
|
||||
import io.element.android.x.matrix.room.MatrixRoom
|
||||
import io.element.android.x.matrix.ui.MatrixItemHelper
|
||||
import io.element.android.x.textcomposer.MessageComposerMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class MessagesPresenter @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val room: MatrixRoom,
|
||||
private val composerPresenter: MessageComposerPresenter,
|
||||
private val timelinePresenter: TimelinePresenter,
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
private val matrixItemHelper = MatrixItemHelper(matrixClient)
|
||||
|
||||
@Composable
|
||||
override fun present(): MessagesState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val composerState = composerPresenter.present()
|
||||
val timelineState = timelinePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
|
||||
val roomName: MutableState<String?> = rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val roomAvatar: MutableState<AvatarData?> = remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
LaunchedEffect(syncUpdateFlow) {
|
||||
roomAvatar.value =
|
||||
matrixItemHelper.loadAvatarData(
|
||||
room = room,
|
||||
size = AvatarSize.SMALL
|
||||
)
|
||||
roomName.value = room.name
|
||||
}
|
||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
||||
}
|
||||
fun handleEvents(event: MessagesEvents) {
|
||||
when (event) {
|
||||
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
|
||||
}
|
||||
}
|
||||
return MessagesState(
|
||||
roomId = room.roomId,
|
||||
roomName = roomName.value,
|
||||
roomAvatar = roomAvatar.value,
|
||||
composerState = composerState,
|
||||
timelineState = timelineState,
|
||||
actionListState = actionListState,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.MessageEvent,
|
||||
composerState: MessageComposerState,
|
||||
) = launch {
|
||||
when (action) {
|
||||
TimelineItemAction.Copy -> notImplementedYet()
|
||||
TimelineItemAction.Forward -> notImplementedYet()
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notImplementedYet() {
|
||||
Timber.v("NotImplementedYet")
|
||||
}
|
||||
|
||||
private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) {
|
||||
room.redactEvent(event.id)
|
||||
}
|
||||
|
||||
private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
|
||||
val composerMode = MessageComposerMode.Edit(
|
||||
targetEvent.id,
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvents.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
|
||||
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
|
||||
composerState.eventSink(
|
||||
MessageComposerEvents.SetMode(composerMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.x.features.messages.actionlist.ActionListState
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerState
|
||||
import io.element.android.x.features.messages.timeline.TimelineState
|
||||
import io.element.android.x.matrix.core.RoomId
|
||||
|
||||
@Immutable
|
||||
data class MessagesState(
|
||||
val roomId: RoomId,
|
||||
val roomName: String?,
|
||||
val roomAvatar: AvatarData?,
|
||||
val composerState: MessageComposerState,
|
||||
val timelineState: TimelineState,
|
||||
val actionListState: ActionListState,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
|
||||
)
|
||||
|
||||
package io.element.android.x.features.messages
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.x.core.compose.LogCompositions
|
||||
import io.element.android.x.designsystem.components.avatar.Avatar
|
||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.x.features.messages.actionlist.ActionListEvents
|
||||
import io.element.android.x.features.messages.actionlist.ActionListView
|
||||
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
|
||||
import io.element.android.x.features.messages.timeline.model.TimelineItem
|
||||
import io.element.android.x.features.messages.textcomposer.MessageComposerView
|
||||
import io.element.android.x.features.messages.timeline.TimelineView
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun MessagesView(
|
||||
state: MessagesState,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
||||
|
||||
fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) {
|
||||
Timber.v("OnMessageClicked= ${messageEvent.id}")
|
||||
}
|
||||
|
||||
fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) {
|
||||
Timber.v("OnMessageLongClicked= ${messageEvent.id}")
|
||||
focusManager.clearFocus(force = true)
|
||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent))
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) {
|
||||
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
MessagesViewTopBar(
|
||||
roomTitle = state.roomName,
|
||||
roomAvatar = state.roomAvatar,
|
||||
onBackPressed = onBackPressed
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
MessagesViewContent(
|
||||
state = state,
|
||||
modifier = Modifier.padding(padding),
|
||||
onMessageClicked = ::onMessageClicked,
|
||||
onMessageLongClicked = ::onMessageLongClicked
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(
|
||||
snackbarHostState,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
onActionSelected = ::onActionSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesViewContent(
|
||||
state: MessagesState,
|
||||
modifier: Modifier = Modifier,
|
||||
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
||||
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
// Hide timeline if composer is full screen
|
||||
if (!state.composerState.isFullScreen) {
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked
|
||||
)
|
||||
}
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(Alignment.Bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesViewTopBar(
|
||||
roomTitle: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackPressed) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (roomAvatar != null) {
|
||||
Avatar(roomAvatar)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
text = roomTitle ?: "Unknown room",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user