mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
345 Commits
v4.5.3
...
docs-polish
| Author | SHA1 | Date | |
|---|---|---|---|
| bcce6a18a1 | |||
| 3bce760e05 | |||
| a7c60f0721 | |||
| 94ad68155f | |||
| cb2b5ff97b | |||
| 313c40590f | |||
| d806efc347 | |||
| 743db49215 | |||
| 4fdffdb8aa | |||
| 1699aae906 | |||
| 82eba49b95 | |||
| 218b1a8b7e | |||
| 6c1bba55b4 | |||
| c9fc1547b0 | |||
| 8a3a8455d4 | |||
| 9c437f6204 | |||
| ef698e58d4 | |||
| 01c0ac3d13 | |||
| ed7089902c | |||
| 1ddd4193a0 | |||
| 932875fd9d | |||
| 1899c84ee2 | |||
| 91e03a016f | |||
| a978c12ca3 | |||
| da1f2d9ed6 | |||
| 1ed6179782 | |||
| e15251e354 | |||
| c348dd765f | |||
| 78a23e6b02 | |||
| 590499684d | |||
| 59c50f8088 | |||
| ee4c759706 | |||
| d5bd3a7d68 | |||
| 7159195cb9 | |||
| 45d3855d84 | |||
| 650a6f0758 | |||
| ae6ba0cfb5 | |||
| d9a2317f82 | |||
| 3d8b521c0c | |||
| 1fd5bbbc4f | |||
| 62299cbf0b | |||
| 3eb969d3c5 | |||
| 85dba0f36b | |||
| cb6490ed59 | |||
| 315d830357 | |||
| 00caeae914 | |||
| 607f77d432 | |||
| c254b33753 | |||
| 59f3848056 | |||
| 69aa002c83 | |||
| 8630d1ab12 | |||
| 591aa9eaa5 | |||
| f82fa42cba | |||
| aa441c88db | |||
| 17ee22da72 | |||
| a9957fb46d | |||
| f5c87fdd4c | |||
| 23467a2248 | |||
| 9fa93e40cb | |||
| f4b852d2dd | |||
| f698b7fa9f | |||
| b21afa648d | |||
| b9575cc869 | |||
| 3ea91bc4ad | |||
| b3dce5fdb0 | |||
| 28ad8b8cd5 | |||
| 37d4ef770c | |||
| ba24e40512 | |||
| 8097593f5e | |||
| 23ccd69b5e | |||
| 5e0d6d77b9 | |||
| a06393f520 | |||
| 549ffcefc0 | |||
| c8721e8000 | |||
| 03882367da | |||
| 4d700d113d | |||
| 17bdd2a1d2 | |||
| 80a68012a2 | |||
| 3742906f75 | |||
| ae90edcdb5 | |||
| 9e76aadb0f | |||
| 043544d7ec | |||
| 2bf7d1dddc | |||
| e1741118ce | |||
| 58fb3f7f2d | |||
| 48e92a7e9b | |||
| 5bf16da09d | |||
| 23ca3dd665 | |||
| 2caff25fa2 | |||
| 37f835be8c | |||
| a6c1f2f776 | |||
| acf0dfc38c | |||
| 67f13831ce | |||
| d50e65af20 | |||
| b8d74b74a6 | |||
| 802a28d759 | |||
| c85d43b349 | |||
| 64ad491197 | |||
| ddd8e719ef | |||
| e51f7a51cc | |||
| e66f5d488b | |||
| b386cc83a7 | |||
| 09481e09b6 | |||
| 6913bf1a46 | |||
| e6c87ff00b | |||
| 5f41cf3c52 | |||
| 9edbe2e589 | |||
| b6876712f0 | |||
| 5b4c183466 | |||
| 393238f47c | |||
| aea526f69d | |||
| 13cf1cc004 | |||
| 1766a8bb2c | |||
| 9a5732ab70 | |||
| 2923ca1356 | |||
| 96f0083384 | |||
| febfc396e3 | |||
| 29735a807b | |||
| eb36f64676 | |||
| 4e01970d69 | |||
| e5713087e3 | |||
| b40fc7ff18 | |||
| 06ad2b7972 | |||
| 14eeb4451c | |||
| ead67adeb8 | |||
| c2d70a5107 | |||
| 90dffc975a | |||
| e5ba7caddc | |||
| ec6cee1389 | |||
| 1d16a19373 | |||
| 04c90b4f07 | |||
| 747cbb8e09 | |||
| 834487e548 | |||
| e1b6b54209 | |||
| 6366d60e4c | |||
| fabea36682 | |||
| 991332a809 | |||
| 85537a99e8 | |||
| 717c90c58b | |||
| ccb52e0acd | |||
| d84b30c071 | |||
| 5ae0afe1fe | |||
| d250e503b0 | |||
| afb0ae3d03 | |||
| 1a3f0bed47 | |||
| 1e280fb7e1 | |||
| 6feac55380 | |||
| 93d8eac037 | |||
| a11f99be3d | |||
| da17639309 | |||
| 10301aa742 | |||
| 2148d50393 | |||
| 12fb2a4ec5 | |||
| 8085e5b85c | |||
| 4ba310ec16 | |||
| 865c56f400 | |||
| c510e73256 | |||
| 73638129bc | |||
| 1a7a79d504 | |||
| d3268e4a72 | |||
| 15a93014a5 | |||
| e7735329bc | |||
| 3e222c68eb | |||
| a596bd9011 | |||
| 21a49710a8 | |||
| ce6fdb2558 | |||
| 0baee848a6 | |||
| 6f304bc9e6 | |||
| 1ca0dfffa0 | |||
| 1420084f5e | |||
| 3e03474437 | |||
| 95366e4d1b | |||
| df1775a1e6 | |||
| 30ccea18ab | |||
| 4cd90d74ad | |||
| 7f1214688a | |||
| aa89d0d156 | |||
| 787cd94362 | |||
| ec61a7fc51 | |||
| 9b627534f5 | |||
| 400a3707b2 | |||
| 38a5676b37 | |||
| f00cfa9108 | |||
| afa24722b2 | |||
| ea5cec53bc | |||
| 61dc649c70 | |||
| b20824e16c | |||
| 39330fdce3 | |||
| 6b725a8ef7 | |||
| cbcdeb2b43 | |||
| 4351610eca | |||
| 935d826a21 | |||
| a8c8137ade | |||
| 7b33e1fba8 | |||
| ade7bba97b | |||
| 08dd321311 | |||
| 67961180c9 | |||
| 1093892ede | |||
| ef05fa4905 | |||
| 6a99a4f1ae | |||
| 4895f396a2 | |||
| c3dffc5909 | |||
| af73e5993d | |||
| dfec1cbb02 | |||
| 8d8f7b2524 | |||
| db7b81587f | |||
| 31bb744ba7 | |||
| f4b349162f | |||
| 7f8adf8f03 | |||
| 1f4bb8a224 | |||
| 0c3dc8a6e9 | |||
| 1f15cf54af | |||
| c96ba30018 | |||
| ffea61917d | |||
| f5c11b8faf | |||
| 48b4b23204 | |||
| 9df78c8ac8 | |||
| 450bfe2e17 | |||
| c79eb36a7a | |||
| a58b3a42db | |||
| e344958224 | |||
| 05c4a6c682 | |||
| b2aec6d6a7 | |||
| 09c4609b6c | |||
| f6d2aa7aae | |||
| 92facf58f7 | |||
| 15c36c5a84 | |||
| cea0543e98 | |||
| c0bbe77788 | |||
| 9f8cbe140d | |||
| d0cf550b51 | |||
| a86725480f | |||
| 9ad22e1f6d | |||
| a266bcbae7 | |||
| 1ba210fe77 | |||
| 7898395359 | |||
| aeb732c2f6 | |||
| b665dce383 | |||
| f349f124d8 | |||
| 8212d7a00e | |||
| 36bcb1b26e | |||
| 8d6fe2be99 | |||
| d9571c70f2 | |||
| babbca48f8 | |||
| 8c4e2e57f9 | |||
| 8308651f44 | |||
| ad881bd46a | |||
| c037eb2d24 | |||
| 8b4353deba | |||
| a2be0d35fb | |||
| 1909bdc702 | |||
| 63909defaf | |||
| 8544984b17 | |||
| 563984c0df | |||
| 6500ee5fc9 | |||
| 2a9c138a23 | |||
| 61d8fa02d4 | |||
| 0fac2187f0 | |||
| 1db61be860 | |||
| 06a0dbd0f2 | |||
| 47c6daf0cc | |||
| bcdf502ce6 | |||
| f9e2f4931a | |||
| 8929d15df0 | |||
| 60d6a47bdb | |||
| 2f529535b1 | |||
| 90c9eae283 | |||
| added6105b | |||
| 2df39b5e24 | |||
| 3477dd9400 | |||
| 5282551f3d | |||
| dcadaaf29b | |||
| e9d6baa6ba | |||
| 6ae052a7a1 | |||
| 9f750c2516 | |||
| 1fe46834f2 | |||
| 3db85c7d37 | |||
| 1e4f1b8891 | |||
| 0fcd6d40ee | |||
| aaa4ffe789 | |||
| 8c4720d0cb | |||
| 94b97f6097 | |||
| 85800d96c8 | |||
| 3b4c06111a | |||
| 548d695a82 | |||
| c986a4b88b | |||
| 09940ccf8d | |||
| cfc323862f | |||
| d8cc867099 | |||
| dce8a1dff9 | |||
| 5bc9e014c2 | |||
| b0c9ba05f3 | |||
| 8a2876fca9 | |||
| 00d5f3b769 | |||
| 17f39ec6a0 | |||
| 498ffe8a71 | |||
| 858f0f2650 | |||
| 66ea2d5d71 | |||
| 3dd5b5d835 | |||
| 9127b1bbc6 | |||
| 1657bcf97d | |||
| 428db2f8f4 | |||
| a8fa9b5e58 | |||
| 4cc59d9fbd | |||
| c50306709b | |||
| 37d0bc2f14 | |||
| 2fda0454e3 | |||
| be19af62d9 | |||
| f915eb2a20 | |||
| 2bc1236a2c | |||
| 9db1924268 | |||
| 7a9f220290 | |||
| 8145387f77 | |||
| 063440e735 | |||
| 6724de09c9 | |||
| f379fd0f8c | |||
| 34a3387830 | |||
| 809cc1f234 | |||
| 12200a74ff | |||
| 2643ea9066 | |||
| 840df89ca6 | |||
| 0404b020e6 | |||
| fda41817e9 | |||
| f48cabcc0a | |||
| f123a905d5 | |||
| 9b7fbfd513 | |||
| e21b4d4236 | |||
| 9ec6911005 | |||
| bfc178faf3 | |||
| c4c93f881d | |||
| d7f9e17bcb | |||
| 13706c4f64 | |||
| f2f4b26c35 | |||
| 1b7b9da07c | |||
| 2817306659 | |||
| 5f587c2104 | |||
| f5670c39da | |||
| c0105d135c | |||
| f1a9814faa | |||
| 8f0e7512be | |||
| 7d49209f79 | |||
| b2e285c2c7 | |||
| 54020250dc | |||
| 01acbb970a | |||
| 36cad35d46 |
@@ -0,0 +1,68 @@
|
|||||||
|
name: Bug
|
||||||
|
description: File a bug report/issue
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type:bug", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the bug you encountered.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Multiple selections are possible.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- Mac
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: OS version
|
||||||
|
description: Specify the OS version
|
||||||
|
placeholder: ex. Android 12, Ubuntu 20.04
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: Specify the SimpleX version
|
||||||
|
placeholder: ex. 4.3.2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Current Behavior
|
||||||
|
description: A concise description of what you're experiencing.
|
||||||
|
placeholder: Bug happened!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: A concise description of what you expected to happen.
|
||||||
|
placeholder: No bug should happen!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
3. Click on ...
|
||||||
|
4. See error...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Relevant log output
|
||||||
|
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||||
|
render: shell
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Feature
|
||||||
|
description: Suggest your feature
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type:enhancement", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the bug you encountered.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Multiple selections are possible. If selected input is "all", this considered to be a general feature.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- Mac
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- all
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: Specify the SimpleX version
|
||||||
|
placeholder: ex. 4.3.2
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Feature
|
||||||
|
description: Describe the feature you would like to see added
|
||||||
|
placeholder: SimpleX Chat should make me coffee!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: Question
|
||||||
|
description: Ask your question
|
||||||
|
title: "[Q]: "
|
||||||
|
labels: ["type:question", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Generally, we encourage you to ask questions in our [official group](https://simplex.chat/invitation/#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3Dsimplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D), but you can do it anyway :)
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Question
|
||||||
|
description: Please ask your question in plain english.
|
||||||
|
placeholder: Is SimpleX - chat?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -52,9 +52,9 @@ jobs:
|
|||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||||
- os: ubuntu-18.04
|
- os: ubuntu-22.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-macos-x86-64
|
asset_name: simplex-chat-macos-x86-64
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
run: brew install pkg-config
|
run: brew install pkg-config
|
||||||
|
|
||||||
- name: Unix prepare cabal.project.local for Ubuntu
|
- name: Unix prepare cabal.project.local for Ubuntu
|
||||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "ignore-project: False" >> cabal.project.local
|
echo "ignore-project: False" >> cabal.project.local
|
||||||
@@ -112,8 +112,8 @@ jobs:
|
|||||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||||
|
|
||||||
- name: Unix test
|
- name: Unix test
|
||||||
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
|
if: matrix.os != 'windows-latest'
|
||||||
timeout-minutes: 20
|
timeout-minutes: 30
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cabal test --test-show-details=direct
|
run: cabal test --test-show-details=direct
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,4 @@ website/package-lock.json
|
|||||||
# Ignore test files
|
# Ignore test files
|
||||||
website/.cache
|
website/.cache
|
||||||
website/test/stubs-layout-cache/_includes/*.js
|
website/test/stubs-layout-cache/_includes/*.js
|
||||||
|
apps/android/app/release
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
| Updated 07.02.2023 | Languages: EN, [FR](/docs/lang/fr/README.md) |
|
|
||||||
|
|
||||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
|
||||||
|
|
||||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
|
||||||
|
|
||||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
[](https://www.reddit.com/r/SimpleXChat)
|
[](https://www.reddit.com/r/SimpleXChat)
|
||||||
[](https://mastodon.social/@simplex)
|
[](https://mastodon.social/@simplex)
|
||||||
|
|
||||||
|
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||||
|
|
||||||
|
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||||
|
|
||||||
|
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||||
|
|
||||||
|
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||||
|
|
||||||
|
## Welcome to SimpleX Chat!
|
||||||
|
|
||||||
|
1. 📲 [Install the app](#install-the-app).
|
||||||
|
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||||
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
|
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||||
|
|
||||||
|
[Learn more about SimpleX Chat](#contents).
|
||||||
|
|
||||||
|
## Install the app
|
||||||
|
|
||||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||||
|
|
||||||
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
[](https://play.google.com/store/apps/details?id=chat.simplex.app)
|
||||||
@@ -26,7 +40,107 @@
|
|||||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||||
|
|
||||||
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
## Connect to the team via the app
|
||||||
|
|
||||||
|
- to ask any questions
|
||||||
|
- to suggest any improvements
|
||||||
|
- to share anything relevant
|
||||||
|
|
||||||
|
## Join user groups
|
||||||
|
|
||||||
|
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||||
|
|
||||||
|
You also can:
|
||||||
|
- criticize the app, and make comparisons with other messengers.
|
||||||
|
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||||
|
- share some privacy related publications, infrequently.
|
||||||
|
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||||
|
|
||||||
|
You must:
|
||||||
|
- be polite to other users
|
||||||
|
- avoid spam (too frequent messages, even if they are relevant)
|
||||||
|
- avoid any personal attacks or hostility.
|
||||||
|
- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
|
||||||
|
|
||||||
|
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||||
|
|
||||||
|
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%3D%3D%22%7D)
|
||||||
|
|
||||||
|
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||||
|
|
||||||
|
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||||
|
|
||||||
|
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||||
|
|
||||||
|
## Make a private connection
|
||||||
|
|
||||||
|
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||||
|
|
||||||
|
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||||
|
|
||||||
|
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||||
|
|
||||||
|
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
|
||||||
|
|
||||||
|
## User guide (NEW)
|
||||||
|
|
||||||
|
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||||
|
|
||||||
|
## Help translating SimpleX Chat
|
||||||
|
|
||||||
|
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||||
|
|
||||||
|
Join our translators to help SimpleX grow!
|
||||||
|
|
||||||
|
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||||
|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||||
|
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||||
|
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||||
|
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||||
|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||||
|
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||||
|
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||||
|
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||||
|
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||||
|
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||||
|
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||||
|
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||||
|
|
||||||
|
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||||
|
|
||||||
|
## Contribute
|
||||||
|
|
||||||
|
We would love to have you join the development! You can help us with:
|
||||||
|
|
||||||
|
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||||
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
|
|
||||||
|
## Help us with donations
|
||||||
|
|
||||||
|
Huge thank you to everybody who donated to SimpleX Chat!
|
||||||
|
|
||||||
|
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||||
|
|
||||||
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
|
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
|
It is possible to donate via:
|
||||||
|
|
||||||
|
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||||
|
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||||
|
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
|
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||||
|
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
|
||||||
|
Evgeny
|
||||||
|
|
||||||
|
SimpleX Chat founder
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
@@ -38,16 +152,11 @@
|
|||||||
- [Users own SimpleX network](#users-own-simplex-network)
|
- [Users own SimpleX network](#users-own-simplex-network)
|
||||||
- [Frequently asked questions](#frequently-asked-questions)
|
- [Frequently asked questions](#frequently-asked-questions)
|
||||||
- [News and updates](#news-and-updates)
|
- [News and updates](#news-and-updates)
|
||||||
- [Make a private connection](#make-a-private-connection)
|
|
||||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||||
- [SimpleX Platform design](#simplex-platform-design)
|
- [SimpleX Platform design](#simplex-platform-design)
|
||||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Roadmap](#roadmap)
|
- [Roadmap](#roadmap)
|
||||||
- [Join a user group](#join-a-user-group)
|
|
||||||
- [Translate the apps](#translate-the-apps)
|
|
||||||
- [Contribute](#contribute)
|
|
||||||
- [Help us with donations](#help-us-with-donations)
|
|
||||||
- [Disclaimers, Security contact, License](#disclaimers)
|
- [Disclaimers, Security contact, License](#disclaimers)
|
||||||
|
|
||||||
## Why privacy matters
|
## Why privacy matters
|
||||||
@@ -88,26 +197,24 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent updates:
|
Recent updates:
|
||||||
|
|
||||||
[Feb 04, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||||
|
|
||||||
[Jan 03, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||||
|
|
||||||
[Dec 06, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
|
|
||||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||||
|
|
||||||
|
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||||
|
|
||||||
|
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||||
|
|
||||||
|
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||||
|
|
||||||
[All updates](./blog)
|
[All updates](./blog)
|
||||||
|
|
||||||
## Make a private connection
|
|
||||||
|
|
||||||
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
|
|
||||||
|
|
||||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
|
||||||
|
|
||||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
|
||||||
|
|
||||||
## :zap: Quick installation of a terminal app
|
## :zap: Quick installation of a terminal app
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -202,17 +309,23 @@ If you are considering developing with SimpleX platform please get in touch for
|
|||||||
- ✅ Multiple user profiles in the same chat database.
|
- ✅ Multiple user profiles in the same chat database.
|
||||||
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
||||||
- ✅ Preserve message drafts.
|
- ✅ Preserve message drafts.
|
||||||
- 🏗 File server to optimize for efficient and private sending of large files.
|
- ✅ File server to optimize for efficient and private sending of large files.
|
||||||
- 🏗 Improved audio & video calls.
|
- ✅ Improved audio & video calls.
|
||||||
|
- ✅ Support older Android OS and 32-bit CPUs.
|
||||||
|
- ✅ Hidden chat profiles.
|
||||||
|
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
|
- ✅ Video messages.
|
||||||
|
- ✅ App access passcode.
|
||||||
|
- 🏗 Improved Android app UI design.
|
||||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||||
- 🏗 Reduced battery and traffic usage in large groups.
|
- 🏗 Reduced battery and traffic usage in large groups.
|
||||||
- 🏗 Support older Android OS and 32-bit CPUs.
|
- Include optional message into connection request sent via contact address.
|
||||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||||
- Access password/pin (with optional alternative access password).
|
- Optional alternative access password.
|
||||||
- Local app files encryption.
|
- Local app files encryption.
|
||||||
- Video messages.
|
|
||||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||||
|
- Privately share your location.
|
||||||
- Feeds/broadcasts.
|
- Feeds/broadcasts.
|
||||||
- Web widgets for custom interactivity in the chats.
|
- Web widgets for custom interactivity in the chats.
|
||||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||||
@@ -225,70 +338,6 @@ If you are considering developing with SimpleX platform please get in touch for
|
|||||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
- High capacity multi-node SMP relays.
|
- High capacity multi-node SMP relays.
|
||||||
|
|
||||||
## Join a user group
|
|
||||||
|
|
||||||
You can join an English-speaking group if you want to ask any questions: [#SimpleX-Group-2](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FQP8zaGjjmlXV-ix_Er4JgJ0lNPYGS1KX%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEApAgBkRZ3x12ayZ7sHrjHQWNMvqzZpWUgM_fFCUdLXwo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xWpPXEZZsQp_F7vwAcAYDw%3D%3D%22%7D)
|
|
||||||
|
|
||||||
There are also several groups in languages other than English, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users. We do not always answer questions there, so please ask them in one of the English-speaking groups.
|
|
||||||
|
|
||||||
- [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking).
|
|
||||||
- [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking).
|
|
||||||
- [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking).
|
|
||||||
- [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
|
||||||
|
|
||||||
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
|
|
||||||
|
|
||||||
Join via the app to share what's going on and ask any questions!
|
|
||||||
|
|
||||||
## Translate the apps
|
|
||||||
|
|
||||||
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps are translated to many other languages. Join our translators to help SimpleX grow faster!
|
|
||||||
|
|
||||||
Current interface languages:
|
|
||||||
|
|
||||||
- English (development language)
|
|
||||||
- German: [@mlanp](https://github.com/mlanp)
|
|
||||||
- French: [@ishi_sama](https://github.com/ishi-sama)
|
|
||||||
- Italian: [@unbranched](https://github.com/unbranched)
|
|
||||||
- Russian: project team
|
|
||||||
|
|
||||||
Languages in progress: Chinese, Hindi, Czech, Japanese, Dutch and [many others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
|
||||||
|
|
||||||
## Contribute
|
|
||||||
|
|
||||||
We would love to have you join the development! You can contribute to SimpleX Chat with:
|
|
||||||
|
|
||||||
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
|
|
||||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
|
||||||
|
|
||||||
## Help us with donations
|
|
||||||
|
|
||||||
Huge thank you to everybody who donated to SimpleX Chat!
|
|
||||||
|
|
||||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
|
||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
|
||||||
|
|
||||||
It is possible to donate via:
|
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
|
||||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
|
||||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
|
||||||
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
|
||||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
|
||||||
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
|
||||||
|
|
||||||
Thank you,
|
|
||||||
|
|
||||||
Evgeny
|
|
||||||
|
|
||||||
SimpleX Chat founder
|
|
||||||
|
|
||||||
## Disclaimers
|
## Disclaimers
|
||||||
|
|
||||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "chat.simplex.app"
|
applicationId "chat.simplex.app"
|
||||||
minSdk 29
|
minSdk 26
|
||||||
targetSdk 32
|
targetSdk 32
|
||||||
versionCode 102
|
versionCode 117
|
||||||
versionName "4.5.3"
|
versionName "5.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ndk {
|
|
||||||
abiFilters 'arm64-v8a'
|
|
||||||
}
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary true
|
useSupportLibrary true
|
||||||
}
|
}
|
||||||
@@ -77,9 +74,37 @@ android {
|
|||||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||||
}
|
}
|
||||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
if (isRelease) {
|
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||||
|
// if (isRelease) {
|
||||||
// Comma separated list of languages that will be included in the apk
|
// Comma separated list of languages that will be included in the apk
|
||||||
android.defaultConfig.resConfigs("en", "ru", "de", "fr", "it", "nl", "cs")
|
android.defaultConfig.resConfigs(
|
||||||
|
"en",
|
||||||
|
"cs",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"ru",
|
||||||
|
"zh-rCN"
|
||||||
|
)
|
||||||
|
// }
|
||||||
|
if (isBundle) {
|
||||||
|
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
if (isRelease) {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +119,8 @@ dependencies {
|
|||||||
implementation 'androidx.fragment:fragment:1.4.1'
|
implementation 'androidx.fragment:fragment:1.4.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
implementation 'com.charleskorn.kaml:kaml:0.43.0'
|
||||||
|
//implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||||
@@ -129,6 +155,9 @@ dependencies {
|
|||||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||||
|
|
||||||
|
// Video support
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
@@ -136,19 +165,12 @@ dependencies {
|
|||||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
def buildType = "unknown"
|
|
||||||
// Don't do anything if no compression is needed
|
// Don't do anything if no compression is needed
|
||||||
if (compression_level != "0") {
|
if (compression_level != "0") {
|
||||||
tasks.whenTaskAdded { task ->
|
tasks.whenTaskAdded { task ->
|
||||||
if (task.name == 'packageDebug') {
|
if (task.name == 'packageDebug') {
|
||||||
task.doLast {
|
|
||||||
buildType = "debug"
|
|
||||||
}
|
|
||||||
task.finalizedBy compressApk
|
task.finalizedBy compressApk
|
||||||
} else if (task.name == 'packageRelease') {
|
} else if (task.name == 'packageRelease') {
|
||||||
task.doLast {
|
|
||||||
buildType = "release"
|
|
||||||
}
|
|
||||||
task.finalizedBy compressApk
|
task.finalizedBy compressApk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +178,13 @@ if (compression_level != "0") {
|
|||||||
|
|
||||||
tasks.register("compressApk") {
|
tasks.register("compressApk") {
|
||||||
doLast {
|
doLast {
|
||||||
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
|
def buildType
|
||||||
|
if (isRelease) {
|
||||||
|
buildType = "release"
|
||||||
|
} else {
|
||||||
|
buildType = "debug"
|
||||||
|
}
|
||||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||||
def keyAlias = ""
|
def keyAlias = ""
|
||||||
@@ -196,6 +225,8 @@ tasks.register("compressApk") {
|
|||||||
|
|
||||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
|
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||||
|
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// View all gradle properties set
|
// View all gradle properties set
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
android:extractNativeLibs="${extract_native_libs}"
|
android:extractNativeLibs="${extract_native_libs}"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SimpleX">
|
android:theme="@style/Theme.SimpleX">
|
||||||
|
<!-- android:localeConfig="@xml/locales_config"-->
|
||||||
|
|
||||||
<!-- Main activity -->
|
<!-- Main activity -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ add_library( support SHARED IMPORTED )
|
|||||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||||
|
|
||||||
add_library( crypto SHARED IMPORTED )
|
|
||||||
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
|
||||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
|
|
||||||
|
|
||||||
# Specifies libraries CMake should link to your target library. You
|
# Specifies libraries CMake should link to your target library. You
|
||||||
# can link multiple libraries, such as libraries you define in this
|
# can link multiple libraries, such as libraries you define in this
|
||||||
# build script, prebuilt third-party libraries, or system libraries.
|
# build script, prebuilt third-party libraries, or system libraries.
|
||||||
@@ -64,7 +60,7 @@ set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
|||||||
target_link_libraries( # Specifies the target library.
|
target_link_libraries( # Specifies the target library.
|
||||||
app-lib
|
app-lib
|
||||||
|
|
||||||
simplex support crypto
|
simplex support
|
||||||
|
|
||||||
# Links the target library to the log library
|
# Links the target library to the log library
|
||||||
# included in the NDK.
|
# included in the NDK.
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ void hs_init(int * argc, char **argv[]);
|
|||||||
void setLineBuffering(void);
|
void setLineBuffering(void);
|
||||||
int pipe_std_to_socket(const char * name);
|
int pipe_std_to_socket(const char * name);
|
||||||
|
|
||||||
|
extern void __svfscanf(void){};
|
||||||
|
extern void __vfwscanf(void){};
|
||||||
|
extern void __memset_chk_fail(void){};
|
||||||
|
extern void __strcpy_chk_generic(void){};
|
||||||
|
extern void __strcat_chk_generic(void){};
|
||||||
|
extern void __libc_globals(void){};
|
||||||
|
extern void __rel_iplt_start(void){};
|
||||||
|
|
||||||
|
// Android 9 only, not 13
|
||||||
|
extern void reallocarray(void){};
|
||||||
|
|
||||||
JNIEXPORT jint JNICALL
|
JNIEXPORT jint JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||||
@@ -24,21 +35,24 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
|||||||
// from simplex-chat
|
// from simplex-chat
|
||||||
typedef long* chat_ctrl;
|
typedef long* chat_ctrl;
|
||||||
|
|
||||||
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
|
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||||
extern char *chat_parse_markdown(const char *str);
|
extern char *chat_parse_markdown(const char *str);
|
||||||
extern char *chat_parse_server(const char *str);
|
extern char *chat_parse_server(const char *str);
|
||||||
|
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||||
|
|
||||||
JNIEXPORT jobjectArray JNICALL
|
JNIEXPORT jobjectArray JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||||
|
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||||
jlong _ctrl = (jlong) 0;
|
jlong _ctrl = (jlong) 0;
|
||||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||||
|
|
||||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||||
@@ -85,3 +99,13 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass
|
|||||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||||
|
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||||
|
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ package chat.simplex.app
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.*
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.os.SystemClock.elapsedRealtime
|
import android.os.SystemClock.elapsedRealtime
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -14,24 +12,22 @@ import androidx.activity.viewModels
|
|||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.Surface
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||||
import chat.simplex.app.model.NtfManager
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||||
import chat.simplex.app.ui.theme.SimpleButton
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.SplashView
|
import chat.simplex.app.views.SplashView
|
||||||
import chat.simplex.app.views.call.ActiveCallView
|
import chat.simplex.app.views.call.ActiveCallView
|
||||||
import chat.simplex.app.views.call.IncomingCallAlertView
|
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||||
@@ -39,11 +35,13 @@ import chat.simplex.app.views.chat.ChatView
|
|||||||
import chat.simplex.app.views.chatlist.*
|
import chat.simplex.app.views.chatlist.*
|
||||||
import chat.simplex.app.views.database.DatabaseErrorView
|
import chat.simplex.app.views.database.DatabaseErrorView
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||||
|
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||||
import chat.simplex.app.views.newchat.*
|
import chat.simplex.app.views.newchat.*
|
||||||
import chat.simplex.app.views.onboarding.*
|
import chat.simplex.app.views.onboarding.*
|
||||||
import kotlinx.coroutines.delay
|
import chat.simplex.app.views.usersettings.LAMode
|
||||||
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class MainActivity: FragmentActivity() {
|
class MainActivity: FragmentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -68,6 +66,7 @@ class MainActivity: FragmentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// testJson()
|
// testJson()
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
|
applyAppLocale(m.controller.appPrefs.appLanguage)
|
||||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||||
// Only needed to be processed on first creation of activity
|
// Only needed to be processed on first creation of activity
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
@@ -84,20 +83,14 @@ class MainActivity: FragmentActivity() {
|
|||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
MainPage(
|
||||||
Modifier
|
m,
|
||||||
.background(MaterialTheme.colors.background)
|
userAuthorized,
|
||||||
.fillMaxSize()
|
laFailed,
|
||||||
) {
|
::runAuthenticate,
|
||||||
MainPage(
|
::setPerformLA,
|
||||||
m,
|
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
|
||||||
userAuthorized,
|
)
|
||||||
laFailed,
|
|
||||||
::runAuthenticate,
|
|
||||||
::setPerformLA,
|
|
||||||
showLANotice = { m.controller.showLANotice(this) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||||
@@ -110,10 +103,11 @@ class MainActivity: FragmentActivity() {
|
|||||||
processExternalIntent(intent, vm.chatModel)
|
processExternalIntent(intent, vm.chatModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onResume() {
|
||||||
super.onStart()
|
super.onResume()
|
||||||
val enteredBackgroundVal = enteredBackground.value
|
val enteredBackgroundVal = enteredBackground.value
|
||||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
|
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||||
|
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||||
runAuthenticate()
|
runAuthenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +124,7 @@ class MainActivity: FragmentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
VideoPlayer.stopAll()
|
||||||
enteredBackground.value = elapsedRealtime()
|
enteredBackground.value = elapsedRealtime()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,43 +156,155 @@ class MainActivity: FragmentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
userAuthorized.value = false
|
userAuthorized.value = false
|
||||||
ModalManager.shared.closeModals()
|
ModalManager.shared.closeModals()
|
||||||
authenticate(
|
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||||
generalGetString(R.string.auth_unlock),
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
generalGetString(R.string.auth_log_in_using_credential),
|
delay(50)
|
||||||
this@MainActivity,
|
withContext(Dispatchers.Main) {
|
||||||
completed = { laResult ->
|
authenticate(
|
||||||
when (laResult) {
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
LAResult.Success ->
|
generalGetString(R.string.auth_unlock)
|
||||||
userAuthorized.value = true
|
else
|
||||||
is LAResult.Error, LAResult.Failed ->
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
laFailed.value = true
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
LAResult.Unavailable -> {
|
generalGetString(R.string.auth_log_in_using_credential)
|
||||||
userAuthorized.value = true
|
else
|
||||||
m.performLA.value = false
|
generalGetString(R.string.auth_unlock),
|
||||||
m.controller.appPrefs.performLA.set(false)
|
this@MainActivity,
|
||||||
laUnavailableTurningOffAlert()
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success ->
|
||||||
|
userAuthorized.value = true
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
laFailed.value = true
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
userAuthorized.value = true
|
||||||
|
m.performLA.value = false
|
||||||
|
m.controller.appPrefs.performLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
|
Log.d(TAG, "showLANotice")
|
||||||
|
if (!laNoticeShown.get()) {
|
||||||
|
laNoticeShown.set(true)
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||||
|
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||||
|
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||||
|
onConfirm = {
|
||||||
|
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||||
|
showChooseLAMode(laNoticeShown, activity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPerformLA(on: Boolean) {
|
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
Log.d(TAG, "showLANotice")
|
||||||
if (on) {
|
laNoticeShown.set(true)
|
||||||
enableLA()
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
} else {
|
title = generalGetString(R.string.la_lock_mode),
|
||||||
disableLA()
|
text = null,
|
||||||
}
|
confirmText = generalGetString(R.string.la_lock_mode_passcode),
|
||||||
|
dismissText = generalGetString(R.string.la_lock_mode_system),
|
||||||
|
onConfirm = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
setPasscode()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
initialEnableLA(activity)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableLA() {
|
private fun initialEnableLA(activity: FragmentActivity) {
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
|
val appPrefs = m.controller.appPrefs
|
||||||
|
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_enable_simplex_lock),
|
generalGetString(R.string.auth_enable_simplex_lock),
|
||||||
generalGetString(R.string.auth_confirm_credential),
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
this@MainActivity,
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
m.showAdvertiseLAUnavailableAlert.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPasscode() {
|
||||||
|
val chatModel = vm.chatModel
|
||||||
|
val appPrefs = chatModel.controller.appPrefs
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
SetAppPasscodeView(
|
||||||
|
submit = {
|
||||||
|
chatModel.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
},
|
||||||
|
cancel = {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laPasscodeNotSetAlert()
|
||||||
|
},
|
||||||
|
close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
|
||||||
|
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||||
|
if (on) {
|
||||||
|
enableLA(activity)
|
||||||
|
} else {
|
||||||
|
disableLA(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableLA(activity: FragmentActivity) {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.new_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
"",
|
||||||
|
activity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
val prefPerformLA = m.controller.appPrefs.performLA
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
@@ -206,11 +313,13 @@ class MainActivity: FragmentActivity() {
|
|||||||
prefPerformLA.set(true)
|
prefPerformLA.set(true)
|
||||||
laTurnedOnAlert()
|
laTurnedOnAlert()
|
||||||
}
|
}
|
||||||
is LAResult.Error, LAResult.Failed -> {
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
}
|
}
|
||||||
LAResult.Unavailable -> {
|
is LAResult.Unavailable -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
laUnavailableInstructionAlert()
|
laUnavailableInstructionAlert()
|
||||||
@@ -220,24 +329,33 @@ class MainActivity: FragmentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableLA() {
|
private fun disableLA(activity: FragmentActivity) {
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_disable_simplex_lock),
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
generalGetString(R.string.auth_confirm_credential),
|
generalGetString(R.string.auth_disable_simplex_lock)
|
||||||
this@MainActivity,
|
else
|
||||||
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.auth_disable_simplex_lock),
|
||||||
|
activity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
val prefPerformLA = m.controller.appPrefs.performLA
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
LAResult.Success -> {
|
LAResult.Success -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
|
ksAppPassword.remove()
|
||||||
}
|
}
|
||||||
is LAResult.Error, LAResult.Failed -> {
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
m.performLA.value = true
|
m.performLA.value = true
|
||||||
prefPerformLA.set(true)
|
prefPerformLA.set(true)
|
||||||
|
laFailedAlert()
|
||||||
}
|
}
|
||||||
LAResult.Unavailable -> {
|
is LAResult.Unavailable -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
laUnavailableTurningOffAlert()
|
laUnavailableTurningOffAlert()
|
||||||
@@ -259,17 +377,9 @@ fun MainPage(
|
|||||||
userAuthorized: MutableState<Boolean?>,
|
userAuthorized: MutableState<Boolean?>,
|
||||||
laFailed: MutableState<Boolean>,
|
laFailed: MutableState<Boolean>,
|
||||||
runAuthenticate: () -> Unit,
|
runAuthenticate: () -> Unit,
|
||||||
setPerformLA: (Boolean) -> Unit,
|
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||||
showLANotice: () -> Unit
|
showLANotice: () -> Unit
|
||||||
) {
|
) {
|
||||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
|
||||||
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(userAuthorized.value) {
|
|
||||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
|
||||||
delay(500L)
|
|
||||||
}
|
|
||||||
chatsAccessAuthorized = userAuthorized.value == true
|
|
||||||
}
|
|
||||||
var showChatDatabaseError by rememberSaveable {
|
var showChatDatabaseError by rememberSaveable {
|
||||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||||
}
|
}
|
||||||
@@ -304,12 +414,12 @@ fun MainPage(
|
|||||||
@Composable
|
@Composable
|
||||||
fun authView() {
|
fun authView() {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.auth_unlock),
|
stringResource(R.string.auth_unlock),
|
||||||
icon = Icons.Outlined.Lock,
|
icon = painterResource(R.drawable.ic_lock),
|
||||||
click = {
|
click = {
|
||||||
laFailed.value = false
|
laFailed.value = false
|
||||||
runAuthenticate()
|
runAuthenticate()
|
||||||
@@ -328,7 +438,7 @@ fun MainPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onboarding == null || userCreated == null -> SplashView()
|
onboarding == null || userCreated == null -> SplashView()
|
||||||
!chatsAccessAuthorized -> {
|
userAuthorized.value != true -> {
|
||||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||||
authView()
|
authView()
|
||||||
} else {
|
} else {
|
||||||
@@ -395,6 +505,14 @@ fun MainPage(
|
|||||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||||
AlertManager.shared.showInView()
|
AlertManager.shared.showInView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffectOnRotate {
|
||||||
|
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||||
|
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||||
|
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||||
|
enteredBackground.value = elapsedRealtime() + 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
@@ -405,8 +523,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||||
if (chatId != null) {
|
if (chatId != null) {
|
||||||
withBGApi {
|
withBGApi {
|
||||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
chatModel.controller.changeActiveUser(userId)
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
}
|
}
|
||||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
@@ -417,8 +536,9 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
NtfManager.ShowChatsAction -> {
|
NtfManager.ShowChatsAction -> {
|
||||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||||
withBGApi {
|
withBGApi {
|
||||||
if (userId != null && userId != chatModel.currentUser.value?.userId) {
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
chatModel.controller.changeActiveUser(userId)
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
}
|
}
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
@@ -455,14 +575,23 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
when {
|
when {
|
||||||
"text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
intent.type == "text/plain" -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Text(it)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
if (text != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Text(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
isMediaIntent(intent) -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
} // All other mime types
|
if (uri != null) {
|
||||||
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
} // All other mime types
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
|
if (uri != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,16 +599,23 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
// Close active chat and show a list of chats
|
// Close active chat and show a list of chats
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
|
Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
|
||||||
when {
|
when {
|
||||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>)?.let {
|
isMediaIntent(intent) -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||||
} // All other mime types
|
if (uris != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||||
|
} // All other mime types
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isMediaIntent(intent: Intent): Boolean =
|
||||||
|
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||||
|
|
||||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||||
if (chatModel.currentUser.value == null) {
|
if (chatModel.currentUser.value == null) {
|
||||||
@@ -491,7 +627,7 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
|||||||
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
||||||
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
||||||
}
|
}
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = title,
|
title = title,
|
||||||
text = if (linkType == ConnectionLinkType.GROUP)
|
text = if (linkType == ConnectionLinkType.GROUP)
|
||||||
generalGetString(R.string.you_will_join_group)
|
generalGetString(R.string.you_will_join_group)
|
||||||
@@ -508,6 +644,20 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||||
|
// Still decrypting database
|
||||||
|
if (chatModel.chatRunning.value == null) {
|
||||||
|
val step = 50L
|
||||||
|
for (i in 0..(timeout / step)) {
|
||||||
|
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//fun testJson() {
|
//fun testJson() {
|
||||||
// val str: String = """
|
// val str: String = """
|
||||||
// """.trimIndent()
|
// """.trimIndent()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.DefaultTheme
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
@@ -26,22 +27,26 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
|||||||
|
|
||||||
// SimpleX API
|
// SimpleX API
|
||||||
typealias ChatCtrl = Long
|
typealias ChatCtrl = Long
|
||||||
external fun chatMigrateInit(dbPath: String, dbKey: String): Array<Any>
|
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||||
external fun chatParseMarkdown(str: String): String
|
external fun chatParseMarkdown(str: String): String
|
||||||
external fun chatParseServer(str: String): String
|
external fun chatParseServer(str: String): String
|
||||||
|
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||||
|
|
||||||
class SimplexApp: Application(), LifecycleEventObserver {
|
class SimplexApp: Application(), LifecycleEventObserver {
|
||||||
lateinit var chatController: ChatController
|
lateinit var chatController: ChatController
|
||||||
|
|
||||||
var isAppOnForeground: Boolean = false
|
var isAppOnForeground: Boolean = false
|
||||||
|
|
||||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
val defaultLocale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
|
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||||
|
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||||
val res: DBMigrationResult = kotlin.runCatching {
|
val res: DBMigrationResult = kotlin.runCatching {
|
||||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||||
@@ -91,6 +96,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
initChatController()
|
initChatController()
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||||
|
runMigrations()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
@@ -180,6 +186,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
MessagesFetcherWorker.scheduleWork()
|
MessagesFetcherWorker.scheduleWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun runMigrations() {
|
||||||
|
val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
|
||||||
|
if (lastMigration.get() < BuildConfig.VERSION_CODE) {
|
||||||
|
while (true) {
|
||||||
|
if (lastMigration.get() < 117) {
|
||||||
|
if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||||
|
chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||||
|
}
|
||||||
|
lastMigration.set(117)
|
||||||
|
} else {
|
||||||
|
lastMigration.set(BuildConfig.VERSION_CODE)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var context: SimplexApp private set
|
lateinit var context: SimplexApp private set
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package chat.simplex.app.model
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.font.*
|
import androidx.compose.ui.text.font.*
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
@@ -54,10 +53,8 @@ class ChatModel(val controller: ChatController) {
|
|||||||
|
|
||||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||||
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
||||||
val userSMPServers = mutableStateOf<(List<ServerCfg>)?>(null)
|
|
||||||
// Allows to temporary save servers that are being edited on multiple screens
|
// Allows to temporary save servers that are being edited on multiple screens
|
||||||
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
|
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
|
||||||
val presetSMPServers = mutableStateOf<(List<String>)?>(null)
|
|
||||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||||
|
|
||||||
// set when app opened from external intent
|
// set when app opened from external intent
|
||||||
@@ -94,6 +91,32 @@ class ChatModel(val controller: ChatController) {
|
|||||||
val filesToDelete = mutableSetOf<File>()
|
val filesToDelete = mutableSetOf<File>()
|
||||||
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
|
||||||
|
|
||||||
|
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||||
|
currentUser.value
|
||||||
|
} else {
|
||||||
|
users.firstOrNull { it.user.userId == userId }?.user
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUserIndex(user: User): Int =
|
||||||
|
users.indexOfFirst { it.user.userId == user.userId }
|
||||||
|
|
||||||
|
fun updateUser(user: User) {
|
||||||
|
val i = getUserIndex(user)
|
||||||
|
if (i != -1) {
|
||||||
|
users[i] = users[i].copy(user = user)
|
||||||
|
}
|
||||||
|
if (currentUser.value?.userId == user.userId) {
|
||||||
|
currentUser.value = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeUser(user: User) {
|
||||||
|
val i = getUserIndex(user)
|
||||||
|
if (i != -1 && users[i].user.userId != currentUser.value?.userId) {
|
||||||
|
users.removeAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||||
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||||
@@ -166,10 +189,13 @@ class ChatModel(val controller: ChatController) {
|
|||||||
// add to current chat
|
// add to current chat
|
||||||
if (chatId.value == cInfo.id) {
|
if (chatId.value == cInfo.id) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
// Prevent situation when chat item already in the list received from backend
|
||||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
if (chatItems.none { it.id == cItem.id }) {
|
||||||
} else {
|
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||||
chatItems.add(cItem)
|
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||||
|
} else {
|
||||||
|
chatItems.add(cItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,19 +222,19 @@ class ChatModel(val controller: ChatController) {
|
|||||||
res = true
|
res = true
|
||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if (chatId.value == cInfo.id) {
|
return if (chatId.value == cInfo.id) {
|
||||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
withContext(Dispatchers.Main) {
|
||||||
if (itemIndex >= 0) {
|
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||||
chatItems[itemIndex] = cItem
|
if (itemIndex >= 0) {
|
||||||
return false
|
chatItems[itemIndex] = cItem
|
||||||
} else {
|
false
|
||||||
withContext(Dispatchers.Main) {
|
} else {
|
||||||
chatItems.add(cItem)
|
chatItems.add(cItem)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,13 +448,19 @@ data class User(
|
|||||||
val localDisplayName: String,
|
val localDisplayName: String,
|
||||||
val profile: LocalProfile,
|
val profile: LocalProfile,
|
||||||
val fullPreferences: FullChatPreferences,
|
val fullPreferences: FullChatPreferences,
|
||||||
val activeUser: Boolean
|
val activeUser: Boolean,
|
||||||
|
val showNtfs: Boolean,
|
||||||
|
val viewPwdHash: UserPwdHash?
|
||||||
): NamedChat {
|
): NamedChat {
|
||||||
override val displayName: String get() = profile.displayName
|
override val displayName: String get() = profile.displayName
|
||||||
override val fullName: String get() = profile.fullName
|
override val fullName: String get() = profile.fullName
|
||||||
override val image: String? get() = profile.image
|
override val image: String? get() = profile.image
|
||||||
override val localAlias: String = ""
|
override val localAlias: String = ""
|
||||||
|
|
||||||
|
val hidden: Boolean = viewPwdHash != null
|
||||||
|
|
||||||
|
val showNotifications: Boolean = activeUser || showNtfs
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val sampleData = User(
|
val sampleData = User(
|
||||||
userId = 1,
|
userId = 1,
|
||||||
@@ -436,11 +468,19 @@ data class User(
|
|||||||
localDisplayName = "alice",
|
localDisplayName = "alice",
|
||||||
profile = LocalProfile.sampleData,
|
profile = LocalProfile.sampleData,
|
||||||
fullPreferences = FullChatPreferences.sampleData,
|
fullPreferences = FullChatPreferences.sampleData,
|
||||||
activeUser = true
|
activeUser = true,
|
||||||
|
showNtfs = true,
|
||||||
|
viewPwdHash = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserPwdHash(
|
||||||
|
val hash: String,
|
||||||
|
val salt: String
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UserInfo(
|
data class UserInfo(
|
||||||
val user: User,
|
val user: User,
|
||||||
@@ -486,6 +526,24 @@ data class Chat (
|
|||||||
val chatItems: List<ChatItem>,
|
val chatItems: List<ChatItem>,
|
||||||
val chatStats: ChatStats = ChatStats(),
|
val chatStats: ChatStats = ChatStats(),
|
||||||
) {
|
) {
|
||||||
|
val userCanSend: Boolean
|
||||||
|
get() = when (chatInfo) {
|
||||||
|
is ChatInfo.Direct -> true
|
||||||
|
is ChatInfo.Group -> {
|
||||||
|
val m = chatInfo.groupInfo.membership
|
||||||
|
m.memberActive && m.memberRole >= GroupMemberRole.Member
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
val userIsObserver: Boolean get() = when(chatInfo) {
|
||||||
|
is ChatInfo.Group -> {
|
||||||
|
val m = chatInfo.groupInfo.membership
|
||||||
|
m.memberActive && m.memberRole == GroupMemberRole.Observer
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
val id: String get() = chatInfo.id
|
val id: String get() = chatInfo.id
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -670,6 +728,7 @@ data class Contact(
|
|||||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
||||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
|
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
|
||||||
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
|
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
|
||||||
|
ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
|
||||||
}
|
}
|
||||||
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
|
override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null }
|
||||||
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
override val displayName get() = localAlias.ifEmpty { profile.displayName }
|
||||||
@@ -688,12 +747,14 @@ data class Contact(
|
|||||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
|
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
|
||||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
|
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
|
||||||
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
|
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
|
||||||
|
ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
|
||||||
}
|
}
|
||||||
|
|
||||||
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
|
||||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
|
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
|
||||||
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
|
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
|
||||||
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
|
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
|
||||||
|
ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -823,6 +884,7 @@ data class GroupInfo (
|
|||||||
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
||||||
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
|
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
|
||||||
ChatFeature.Voice -> fullGroupPreferences.voice.on
|
ChatFeature.Voice -> fullGroupPreferences.voice.on
|
||||||
|
ChatFeature.Calls -> false
|
||||||
}
|
}
|
||||||
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
||||||
override val displayName get() = groupProfile.displayName
|
override val displayName get() = groupProfile.displayName
|
||||||
@@ -963,11 +1025,13 @@ class GroupMemberRef(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class GroupMemberRole(val memberRole: String) {
|
enum class GroupMemberRole(val memberRole: String) {
|
||||||
@SerialName("member") Member("member"), // order matters in comparisons
|
@SerialName("observer") Observer("observer"), // order matters in comparisons
|
||||||
|
@SerialName("member") Member("member"),
|
||||||
@SerialName("admin") Admin("admin"),
|
@SerialName("admin") Admin("admin"),
|
||||||
@SerialName("owner") Owner("owner");
|
@SerialName("owner") Owner("owner");
|
||||||
|
|
||||||
val text: String get() = when (this) {
|
val text: String get() = when (this) {
|
||||||
|
Observer -> generalGetString(R.string.group_member_role_observer)
|
||||||
Member -> generalGetString(R.string.group_member_role_member)
|
Member -> generalGetString(R.string.group_member_role_member)
|
||||||
Admin -> generalGetString(R.string.group_member_role_admin)
|
Admin -> generalGetString(R.string.group_member_role_admin)
|
||||||
Owner -> generalGetString(R.string.group_member_role_owner)
|
Owner -> generalGetString(R.string.group_member_role_owner)
|
||||||
@@ -1218,9 +1282,24 @@ data class ChatItem (
|
|||||||
when (content) {
|
when (content) {
|
||||||
is CIContent.SndDeleted -> true
|
is CIContent.SndDeleted -> true
|
||||||
is CIContent.RcvDeleted -> true
|
is CIContent.RcvDeleted -> true
|
||||||
|
is CIContent.SndModerated -> true
|
||||||
|
is CIContent.RcvModerated -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
|
||||||
|
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
|
||||||
|
val m = chatInfo.groupInfo.membership
|
||||||
|
if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) {
|
||||||
|
chatInfo.groupInfo to chatDir.groupMember
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val showNtfDir: Boolean get() = !chatDir.sent
|
private val showNtfDir: Boolean get() = !chatDir.sent
|
||||||
|
|
||||||
val showNotification: Boolean get() =
|
val showNotification: Boolean get() =
|
||||||
@@ -1232,6 +1311,7 @@ data class ChatItem (
|
|||||||
is CIContent.SndCall -> showNtfDir
|
is CIContent.SndCall -> showNtfDir
|
||||||
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
|
is CIContent.RcvCall -> false // notification is shown on CallInvitation instead
|
||||||
is CIContent.RcvIntegrityError -> showNtfDir
|
is CIContent.RcvIntegrityError -> showNtfDir
|
||||||
|
is CIContent.RcvDecryptionError -> showNtfDir
|
||||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||||
is CIContent.SndGroupInvitation -> showNtfDir
|
is CIContent.SndGroupInvitation -> showNtfDir
|
||||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||||
@@ -1389,10 +1469,10 @@ data class ChatItem (
|
|||||||
file = null
|
file = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun invalidJSON(json: String): ChatItem =
|
fun invalidJSON(chatDir: CIDirection?, meta: CIMeta?, json: String): ChatItem =
|
||||||
ChatItem(
|
ChatItem(
|
||||||
chatDir = CIDirection.DirectSnd(),
|
chatDir = chatDir ?: CIDirection.DirectSnd(),
|
||||||
meta = CIMeta.invalidJSON(),
|
meta = meta ?: CIMeta.invalidJSON(),
|
||||||
content = CIContent.InvalidJSON(json),
|
content = CIContent.InvalidJSON(json),
|
||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
file = null
|
file = null
|
||||||
@@ -1436,12 +1516,12 @@ data class CIMeta (
|
|||||||
|
|
||||||
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
|
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
|
||||||
|
|
||||||
fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair<ImageVector, Color>? =
|
fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair<Int, Color>? =
|
||||||
when (itemStatus) {
|
when (itemStatus) {
|
||||||
is CIStatus.SndSent -> Icons.Filled.Check to metaColor
|
is CIStatus.SndSent -> R.drawable.ic_check_filled to metaColor
|
||||||
is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red
|
is CIStatus.SndErrorAuth -> R.drawable.ic_close to Color.Red
|
||||||
is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow
|
is CIStatus.SndError -> R.drawable.ic_warning_filled to WarningYellow
|
||||||
is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor
|
is CIStatus.RcvNew -> R.drawable.ic_circle_filled to primaryColor
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,6 +1615,7 @@ sealed class CIContent: ItemContent {
|
|||||||
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
|
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||||
@@ -1561,6 +1642,7 @@ sealed class CIContent: ItemContent {
|
|||||||
is SndCall -> status.text(duration)
|
is SndCall -> status.text(duration)
|
||||||
is RcvCall -> status.text(duration)
|
is RcvCall -> status.text(duration)
|
||||||
is RcvIntegrityError -> msgError.text
|
is RcvIntegrityError -> msgError.text
|
||||||
|
is RcvDecryptionError -> msgDecryptError.text
|
||||||
is RcvGroupInvitation -> groupInvitation.text
|
is RcvGroupInvitation -> groupInvitation.text
|
||||||
is SndGroupInvitation -> groupInvitation.text
|
is SndGroupInvitation -> groupInvitation.text
|
||||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||||
@@ -1599,6 +1681,17 @@ sealed class CIContent: ItemContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class MsgDecryptError {
|
||||||
|
@SerialName("ratchetHeader") RatchetHeader,
|
||||||
|
@SerialName("tooManySkipped") TooManySkipped;
|
||||||
|
|
||||||
|
val text: String get() = when (this) {
|
||||||
|
RatchetHeader -> generalGetString(R.string.decryption_error)
|
||||||
|
TooManySkipped -> generalGetString(R.string.decryption_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class CIQuote (
|
class CIQuote (
|
||||||
val chatDir: CIDirection? = null,
|
val chatDir: CIDirection? = null,
|
||||||
@@ -1636,18 +1729,40 @@ class CIFile(
|
|||||||
val fileName: String,
|
val fileName: String,
|
||||||
val fileSize: Long,
|
val fileSize: Long,
|
||||||
val filePath: String? = null,
|
val filePath: String? = null,
|
||||||
val fileStatus: CIFileStatus
|
val fileStatus: CIFileStatus,
|
||||||
|
val fileProtocol: FileProtocol
|
||||||
) {
|
) {
|
||||||
val loaded: Boolean = when (fileStatus) {
|
val loaded: Boolean = when (fileStatus) {
|
||||||
CIFileStatus.SndStored -> true
|
is CIFileStatus.SndStored -> true
|
||||||
CIFileStatus.SndTransfer -> true
|
is CIFileStatus.SndTransfer -> true
|
||||||
CIFileStatus.SndComplete -> true
|
is CIFileStatus.SndComplete -> true
|
||||||
CIFileStatus.SndCancelled -> true
|
is CIFileStatus.SndCancelled -> true
|
||||||
CIFileStatus.RcvInvitation -> false
|
is CIFileStatus.SndError -> true
|
||||||
CIFileStatus.RcvAccepted -> false
|
is CIFileStatus.RcvInvitation -> false
|
||||||
CIFileStatus.RcvTransfer -> false
|
is CIFileStatus.RcvAccepted -> false
|
||||||
CIFileStatus.RcvCancelled -> false
|
is CIFileStatus.RcvTransfer -> false
|
||||||
CIFileStatus.RcvComplete -> true
|
is CIFileStatus.RcvCancelled -> false
|
||||||
|
is CIFileStatus.RcvComplete -> true
|
||||||
|
is CIFileStatus.RcvError -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
val cancelAction: CancelAction? = when (fileStatus) {
|
||||||
|
is CIFileStatus.SndStored -> sndCancelAction
|
||||||
|
is CIFileStatus.SndTransfer -> sndCancelAction
|
||||||
|
is CIFileStatus.SndComplete ->
|
||||||
|
if (fileProtocol == FileProtocol.XFTP) {
|
||||||
|
revokeCancelAction
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndCancelled -> null
|
||||||
|
is CIFileStatus.SndError -> null
|
||||||
|
is CIFileStatus.RcvInvitation -> null
|
||||||
|
is CIFileStatus.RcvAccepted -> rcvCancelAction
|
||||||
|
is CIFileStatus.RcvTransfer -> rcvCancelAction
|
||||||
|
is CIFileStatus.RcvCancelled -> null
|
||||||
|
is CIFileStatus.RcvComplete -> null
|
||||||
|
is CIFileStatus.RcvError -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -1658,21 +1773,67 @@ class CIFile(
|
|||||||
filePath: String? = "test.txt",
|
filePath: String? = "test.txt",
|
||||||
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
|
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
|
||||||
): CIFile =
|
): CIFile =
|
||||||
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus)
|
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class CIFileStatus {
|
class CancelAction(
|
||||||
@SerialName("snd_stored") SndStored,
|
val uiActionId: Int,
|
||||||
@SerialName("snd_transfer") SndTransfer,
|
val alert: AlertInfo
|
||||||
@SerialName("snd_complete") SndComplete,
|
)
|
||||||
@SerialName("snd_cancelled") SndCancelled,
|
|
||||||
@SerialName("rcv_invitation") RcvInvitation,
|
@Serializable
|
||||||
@SerialName("rcv_accepted") RcvAccepted,
|
class AlertInfo(
|
||||||
@SerialName("rcv_transfer") RcvTransfer,
|
val titleId: Int,
|
||||||
@SerialName("rcv_complete") RcvComplete,
|
val messageId: Int,
|
||||||
@SerialName("rcv_cancelled") RcvCancelled;
|
val confirmId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sndCancelAction: CancelAction = CancelAction(
|
||||||
|
uiActionId = R.string.stop_file__action,
|
||||||
|
alert = AlertInfo(
|
||||||
|
titleId = R.string.stop_snd_file__title,
|
||||||
|
messageId = R.string.stop_snd_file__message,
|
||||||
|
confirmId = R.string.stop_file__confirm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
private val revokeCancelAction: CancelAction = CancelAction(
|
||||||
|
uiActionId = R.string.revoke_file__action,
|
||||||
|
alert = AlertInfo(
|
||||||
|
titleId = R.string.revoke_file__title,
|
||||||
|
messageId = R.string.revoke_file__message,
|
||||||
|
confirmId = R.string.revoke_file__confirm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
private val rcvCancelAction: CancelAction = CancelAction(
|
||||||
|
uiActionId = R.string.stop_file__action,
|
||||||
|
alert = AlertInfo(
|
||||||
|
titleId = R.string.stop_rcv_file__title,
|
||||||
|
messageId = R.string.stop_rcv_file__message,
|
||||||
|
confirmId = R.string.stop_file__confirm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class FileProtocol {
|
||||||
|
@SerialName("smp") SMP,
|
||||||
|
@SerialName("xftp") XFTP;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class CIFileStatus {
|
||||||
|
@Serializable @SerialName("sndStored") object SndStored: CIFileStatus()
|
||||||
|
@Serializable @SerialName("sndTransfer") class SndTransfer(val sndProgress: Long, val sndTotal: Long): CIFileStatus()
|
||||||
|
@Serializable @SerialName("sndComplete") object SndComplete: CIFileStatus()
|
||||||
|
@Serializable @SerialName("sndCancelled") object SndCancelled: CIFileStatus()
|
||||||
|
@Serializable @SerialName("sndError") object SndError: CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvInvitation") object RcvInvitation: CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvAccepted") object RcvAccepted: CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvTransfer") class RcvTransfer(val rcvProgress: Long, val rcvTotal: Long): CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
|
||||||
|
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
|
||||||
@@ -1683,6 +1844,7 @@ sealed class MsgContent {
|
|||||||
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
|
||||||
|
@Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||||
@@ -1736,6 +1898,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
element<String>("text")
|
element<String>("text")
|
||||||
element<String>("image")
|
element<String>("image")
|
||||||
})
|
})
|
||||||
|
element("MCVideo", buildClassSerialDescriptor("MCVideo") {
|
||||||
|
element<String>("text")
|
||||||
|
element<String>("image")
|
||||||
|
element<Int>("duration")
|
||||||
|
})
|
||||||
element("MCFile", buildClassSerialDescriptor("MCFile") {
|
element("MCFile", buildClassSerialDescriptor("MCFile") {
|
||||||
element<String>("text")
|
element<String>("text")
|
||||||
})
|
})
|
||||||
@@ -1759,6 +1926,11 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||||
MsgContent.MCImage(text, image)
|
MsgContent.MCImage(text, image)
|
||||||
}
|
}
|
||||||
|
"video" -> {
|
||||||
|
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||||
|
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||||
|
MsgContent.MCVideo(text, image, duration)
|
||||||
|
}
|
||||||
"voice" -> {
|
"voice" -> {
|
||||||
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||||
MsgContent.MCVoice(text, duration)
|
MsgContent.MCVoice(text, duration)
|
||||||
@@ -1794,6 +1966,13 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
put("text", value.text)
|
put("text", value.text)
|
||||||
put("image", value.image)
|
put("image", value.image)
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVideo ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "video")
|
||||||
|
put("text", value.text)
|
||||||
|
put("image", value.image)
|
||||||
|
put("duration", value.duration)
|
||||||
|
}
|
||||||
is MsgContent.MCVoice ->
|
is MsgContent.MCVoice ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("type", "voice")
|
put("type", "voice")
|
||||||
@@ -1929,7 +2108,11 @@ enum class CICallStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun durationText(sec: Int): String = "%02d:%02d".format(sec / 60, sec % 60)
|
fun durationText(sec: Int): String {
|
||||||
|
val s = sec % 60
|
||||||
|
val m = sec / 60
|
||||||
|
return if (m < 60) "%02d:%02d".format(m, s) else "%02d:%02d:%02d".format(m / 60, m % 60, s)
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class MsgErrorType() {
|
sealed class MsgErrorType() {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||||
notifyMessageReceived(
|
displayNotification(
|
||||||
user = user,
|
user = user,
|
||||||
chatId = cInfo.id,
|
chatId = cInfo.id,
|
||||||
displayName = cInfo.displayName,
|
displayName = cInfo.displayName,
|
||||||
@@ -91,7 +91,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notifyContactConnected(user: User, contact: Contact) {
|
fun notifyContactConnected(user: User, contact: Contact) {
|
||||||
notifyMessageReceived(
|
displayNotification(
|
||||||
user = user,
|
user = user,
|
||||||
chatId = contact.id,
|
chatId = contact.id,
|
||||||
displayName = contact.displayName,
|
displayName = contact.displayName,
|
||||||
@@ -101,11 +101,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
|
|
||||||
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||||
if (!cInfo.ntfsEnabled) return
|
if (!cInfo.ntfsEnabled) return
|
||||||
|
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||||
notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||||
|
if (!user.showNotifications) return
|
||||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ val MessagePreviewDark = Color(179, 175, 174, 255)
|
|||||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||||
val ToolbarLight = Color(220, 220, 220, 12)
|
val ToolbarLight = Color(220, 220, 220, 12)
|
||||||
val ToolbarDark = Color(80, 80, 80, 12)
|
val ToolbarDark = Color(80, 80, 80, 12)
|
||||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
|
||||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||||
val GroupDark = Color(80, 80, 80, 60)
|
val GroupDark = Color(80, 80, 80, 60)
|
||||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||||
@@ -27,3 +26,4 @@ val WarningOrange = Color(255, 127, 0, 255)
|
|||||||
val WarningYellow = Color(255, 192, 0, 255)
|
val WarningYellow = Color(255, 192, 0, 255)
|
||||||
val FileLight = Color(183, 190, 199, 255)
|
val FileLight = Color(183, 190, 199, 255)
|
||||||
val FileDark = Color(101, 101, 106, 255)
|
val FileDark = Color(101, 101, 106, 255)
|
||||||
|
val MenuTextColorDark = Color.White.copy(alpha = 0.8f)
|
||||||
|
|||||||
@@ -2,55 +2,248 @@ package chat.simplex.app.ui.theme
|
|||||||
|
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
enum class DefaultTheme {
|
enum class DefaultTheme {
|
||||||
SYSTEM, DARK, LIGHT
|
SYSTEM, LIGHT, DARK, SIMPLEX;
|
||||||
|
|
||||||
|
// Call it only with base theme, not SYSTEM
|
||||||
|
fun hasChangedAnyColor(colors: Colors, appColors: AppColors): Boolean {
|
||||||
|
val palette = when (this) {
|
||||||
|
SYSTEM -> return false
|
||||||
|
LIGHT -> LightColorPalette
|
||||||
|
DARK -> DarkColorPalette
|
||||||
|
SIMPLEX -> SimplexColorPalette
|
||||||
|
}
|
||||||
|
val appPalette = when (this) {
|
||||||
|
SYSTEM -> return false
|
||||||
|
LIGHT -> LightColorPaletteApp
|
||||||
|
DARK -> DarkColorPaletteApp
|
||||||
|
SIMPLEX -> SimplexColorPaletteApp
|
||||||
|
}
|
||||||
|
return colors.primary != palette.primary ||
|
||||||
|
colors.primaryVariant != palette.primaryVariant ||
|
||||||
|
colors.secondary != palette.secondary ||
|
||||||
|
colors.secondaryVariant != palette.secondaryVariant ||
|
||||||
|
colors.background != palette.background ||
|
||||||
|
colors.surface != palette.surface ||
|
||||||
|
appColors != appPalette
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val DEFAULT_PADDING = 16.dp
|
data class AppColors(
|
||||||
|
val title: Color,
|
||||||
|
val sentMessage: Color,
|
||||||
|
val receivedMessage: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ThemeColor {
|
||||||
|
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, RECEIVED_MESSAGE;
|
||||||
|
|
||||||
|
fun fromColors(colors: Colors, appColors: AppColors): Color {
|
||||||
|
return when (this) {
|
||||||
|
PRIMARY -> colors.primary
|
||||||
|
PRIMARY_VARIANT -> colors.primaryVariant
|
||||||
|
SECONDARY -> colors.secondary
|
||||||
|
SECONDARY_VARIANT -> colors.secondaryVariant
|
||||||
|
BACKGROUND -> colors.background
|
||||||
|
SURFACE -> colors.surface
|
||||||
|
TITLE -> appColors.title
|
||||||
|
SENT_MESSAGE -> appColors.sentMessage
|
||||||
|
RECEIVED_MESSAGE -> appColors.receivedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val text: String
|
||||||
|
get() = when (this) {
|
||||||
|
PRIMARY -> generalGetString(R.string.color_primary)
|
||||||
|
PRIMARY_VARIANT -> generalGetString(R.string.color_primary_variant)
|
||||||
|
SECONDARY -> generalGetString(R.string.color_secondary)
|
||||||
|
SECONDARY_VARIANT -> generalGetString(R.string.color_secondary_variant)
|
||||||
|
BACKGROUND -> generalGetString(R.string.color_background)
|
||||||
|
SURFACE -> generalGetString(R.string.color_surface)
|
||||||
|
TITLE -> generalGetString(R.string.color_title)
|
||||||
|
SENT_MESSAGE -> generalGetString(R.string.color_sent_message)
|
||||||
|
RECEIVED_MESSAGE -> generalGetString(R.string.color_received_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeColors(
|
||||||
|
@SerialName("accent")
|
||||||
|
val primary: String? = null,
|
||||||
|
@SerialName("accentVariant")
|
||||||
|
val primaryVariant: String? = null,
|
||||||
|
val secondary: String? = null,
|
||||||
|
val secondaryVariant: String? = null,
|
||||||
|
val background: String? = null,
|
||||||
|
@SerialName("menus")
|
||||||
|
val surface: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val sentMessage: String? = null,
|
||||||
|
val receivedMessage: String? = null,
|
||||||
|
) {
|
||||||
|
fun toColors(base: DefaultTheme): Colors {
|
||||||
|
val baseColors = when (base) {
|
||||||
|
DefaultTheme.LIGHT -> LightColorPalette
|
||||||
|
DefaultTheme.DARK -> DarkColorPalette
|
||||||
|
DefaultTheme.SIMPLEX -> SimplexColorPalette
|
||||||
|
// shouldn't be here
|
||||||
|
DefaultTheme.SYSTEM -> LightColorPalette
|
||||||
|
}
|
||||||
|
return baseColors.copy(
|
||||||
|
primary = primary?.colorFromReadableHex() ?: baseColors.primary,
|
||||||
|
primaryVariant = primaryVariant?.colorFromReadableHex() ?: baseColors.primaryVariant,
|
||||||
|
secondary = secondary?.colorFromReadableHex() ?: baseColors.secondary,
|
||||||
|
secondaryVariant = secondaryVariant?.colorFromReadableHex() ?: baseColors.secondaryVariant,
|
||||||
|
background = background?.colorFromReadableHex() ?: baseColors.background,
|
||||||
|
surface = surface?.colorFromReadableHex() ?: baseColors.surface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAppColors(base: DefaultTheme): AppColors {
|
||||||
|
val baseColors = when (base) {
|
||||||
|
DefaultTheme.LIGHT -> LightColorPaletteApp
|
||||||
|
DefaultTheme.DARK -> DarkColorPaletteApp
|
||||||
|
DefaultTheme.SIMPLEX -> SimplexColorPaletteApp
|
||||||
|
// shouldn't be here
|
||||||
|
DefaultTheme.SYSTEM -> LightColorPaletteApp
|
||||||
|
}
|
||||||
|
return baseColors.copy(
|
||||||
|
title = title?.colorFromReadableHex() ?: baseColors.title,
|
||||||
|
sentMessage = sentMessage?.colorFromReadableHex() ?: baseColors.sentMessage,
|
||||||
|
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.colorFromReadableHex(): Color =
|
||||||
|
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeOverrides (
|
||||||
|
val base: DefaultTheme,
|
||||||
|
val colors: ThemeColors
|
||||||
|
) {
|
||||||
|
fun withUpdatedColor(name: ThemeColor, color: String): ThemeOverrides {
|
||||||
|
return copy(colors = when (name) {
|
||||||
|
ThemeColor.PRIMARY -> colors.copy(primary = color)
|
||||||
|
ThemeColor.PRIMARY_VARIANT -> colors.copy(primaryVariant = color)
|
||||||
|
ThemeColor.SECONDARY -> colors.copy(secondary = color)
|
||||||
|
ThemeColor.SECONDARY_VARIANT -> colors.copy(secondaryVariant = color)
|
||||||
|
ThemeColor.BACKGROUND -> colors.copy(background = color)
|
||||||
|
ThemeColor.SURFACE -> colors.copy(surface = color)
|
||||||
|
ThemeColor.TITLE -> colors.copy(title = color)
|
||||||
|
ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color)
|
||||||
|
ThemeColor.RECEIVED_MESSAGE -> colors.copy(receivedMessage = color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeData (val colors: ThemeColors)
|
||||||
|
|
||||||
|
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
|
||||||
|
return if (baseTheme == DefaultTheme.SIMPLEX) {
|
||||||
|
this.background(brush = Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
CurrentColors.value.colors.background.darker(0.4f),
|
||||||
|
CurrentColors.value.colors.background.lighter(0.4f)
|
||||||
|
),
|
||||||
|
Offset(0f, Float.POSITIVE_INFINITY),
|
||||||
|
Offset(Float.POSITIVE_INFINITY, 0f)
|
||||||
|
), shape = shape)
|
||||||
|
} else {
|
||||||
|
this.background(color = CurrentColors.value.colors.background, shape = shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val DEFAULT_PADDING = 20.dp
|
||||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||||
|
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||||
|
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
|
||||||
|
|
||||||
val DarkColorPalette = darkColors(
|
val DarkColorPalette = darkColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
primaryVariant = SimplexGreen,
|
primaryVariant = SimplexBlue,
|
||||||
secondary = DarkGray,
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = DarkGray,
|
||||||
// background = Color.Black,
|
// background = Color.Black,
|
||||||
// surface = Color.Black,
|
surface = Color(0xFF222222),
|
||||||
// background = Color(0xFF121212),
|
// background = Color(0xFF121212),
|
||||||
// surface = Color(0xFF121212),
|
// surface = Color(0xFF121212),
|
||||||
// error = Color(0xFFCF6679),
|
error = Color.Red,
|
||||||
onBackground = Color(0xFFFFFBFA),
|
onBackground = Color(0xFFFFFBFA),
|
||||||
onSurface = Color(0xFFFFFBFA),
|
onSurface = Color(0xFFFFFBFA),
|
||||||
// onError: Color = Color.Black,
|
// onError: Color = Color.Black,
|
||||||
)
|
)
|
||||||
|
val DarkColorPaletteApp = AppColors(
|
||||||
|
title = SimplexBlue,
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
val LightColorPalette = lightColors(
|
val LightColorPalette = lightColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
primaryVariant = SimplexGreen,
|
primaryVariant = SimplexBlue,
|
||||||
secondary = LightGray,
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = LightGray,
|
||||||
|
error = Color.Red,
|
||||||
// background = Color.White,
|
// background = Color.White,
|
||||||
// surface = Color.White
|
surface = Color.White,
|
||||||
// onPrimary = Color.White,
|
// onPrimary = Color.White,
|
||||||
// onSecondary = Color.Black,
|
// onSecondary = Color.Black,
|
||||||
// onBackground = Color.Black,
|
// onBackground = Color.Black,
|
||||||
// onSurface = Color.Black,
|
// onSurface = Color.Black,
|
||||||
)
|
)
|
||||||
|
val LightColorPaletteApp = AppColors(
|
||||||
|
title = SimplexBlue,
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
val SimplexColorPalette = darkColors(
|
||||||
|
primary = Color(0xFF70F0F9), // If this value changes also need to update #0088ff in string resource files
|
||||||
|
primaryVariant = Color(0xFF1298A5),
|
||||||
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = Color(0xFF2C464D),
|
||||||
|
background = Color(0xFF111528),
|
||||||
|
// surface = Color.Black,
|
||||||
|
// background = Color(0xFF121212),
|
||||||
|
surface = Color(0xFF121C37),
|
||||||
|
error = Color.Red,
|
||||||
|
// onBackground = Color(0xFFFFFBFA),
|
||||||
|
// onSurface = Color(0xFFFFFBFA),
|
||||||
|
// onError: Color = Color.Black,
|
||||||
|
)
|
||||||
|
val SimplexColorPaletteApp = AppColors(
|
||||||
|
title = Color(0xFF267BE5),
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
|
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
||||||
|
|
||||||
// Non-@Composable implementation
|
// Non-@Composable implementation
|
||||||
private fun isInNightMode() =
|
private fun isInNightMode() =
|
||||||
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
|
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||||
@@ -61,14 +254,14 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
val systemDark = isSystemInDarkTheme()
|
val systemDark = isSystemInDarkTheme()
|
||||||
LaunchedEffect(systemDark) {
|
LaunchedEffect(systemDark) {
|
||||||
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
|
if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
|
||||||
// Change active colors from light to dark and back based on system theme
|
// Change active colors from light to dark and back based on system theme
|
||||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val theme by CurrentColors.collectAsState()
|
val theme by CurrentColors.collectAsState()
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colors = theme.first,
|
colors = theme.colors,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
shapes = Shapes,
|
shapes = Shapes,
|
||||||
content = content
|
content = content
|
||||||
|
|||||||
@@ -7,22 +7,56 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
import okhttp3.internal.toHexString
|
||||||
|
|
||||||
object ThemeManager {
|
object ThemeManager {
|
||||||
private val appPrefs: AppPreferences by lazy {
|
private val appPrefs: AppPreferences by lazy {
|
||||||
AppPreferences(SimplexApp.context)
|
SimplexApp.context.chatModel.controller.appPrefs
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
|
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
|
||||||
val theme = appPrefs.currentTheme.get()!!
|
|
||||||
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
|
private fun systemDarkThemeColors(): Pair<Colors, DefaultTheme> = when (appPrefs.systemDarkTheme.get()) {
|
||||||
val res = when (theme) {
|
DefaultTheme.DARK.name -> DarkColorPalette to DefaultTheme.DARK
|
||||||
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
DefaultTheme.SIMPLEX.name -> SimplexColorPalette to DefaultTheme.SIMPLEX
|
||||||
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
|
else -> SimplexColorPalette to DefaultTheme.SIMPLEX
|
||||||
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
|
}
|
||||||
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
|
||||||
|
fun currentColors(darkForSystemTheme: Boolean): ActiveTheme {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val themeOverrides = appPrefs.themeOverrides.get()
|
||||||
|
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
}
|
}
|
||||||
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
|
val theme = themeOverrides[nonSystemThemeName]
|
||||||
|
val baseTheme = when (nonSystemThemeName) {
|
||||||
|
DefaultTheme.LIGHT.name -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
|
||||||
|
DefaultTheme.DARK.name -> Triple(DefaultTheme.DARK, DarkColorPalette, DarkColorPaletteApp)
|
||||||
|
DefaultTheme.SIMPLEX.name -> Triple(DefaultTheme.SIMPLEX, SimplexColorPalette, SimplexColorPaletteApp)
|
||||||
|
else -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
|
||||||
|
}
|
||||||
|
if (theme == null) {
|
||||||
|
return ActiveTheme(themeName, baseTheme.first, baseTheme.second, baseTheme.third)
|
||||||
|
}
|
||||||
|
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentThemeData(darkForSystemTheme: Boolean): ThemeData {
|
||||||
|
val t = currentColors(darkForSystemTheme)
|
||||||
|
return ThemeData(colors = ThemeColors(
|
||||||
|
primary = t.colors.primary.toReadableHex(),
|
||||||
|
primaryVariant = t.colors.primaryVariant.toReadableHex(),
|
||||||
|
secondary = t.colors.secondary.toReadableHex(),
|
||||||
|
secondaryVariant = t.colors.secondaryVariant.toReadableHex(),
|
||||||
|
background = t.colors.background.toReadableHex(),
|
||||||
|
surface = t.colors.surface.toReadableHex(),
|
||||||
|
title = t.appColors.title.toReadableHex(),
|
||||||
|
sentMessage = t.appColors.sentMessage.toReadableHex(),
|
||||||
|
receivedMessage = t.appColors.receivedMessage.toReadableHex()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// colors, default theme enum, localized name of theme
|
// colors, default theme enum, localized name of theme
|
||||||
@@ -30,7 +64,7 @@ object ThemeManager {
|
|||||||
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
||||||
allThemes.add(
|
allThemes.add(
|
||||||
Triple(
|
Triple(
|
||||||
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
|
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
|
||||||
DefaultTheme.SYSTEM,
|
DefaultTheme.SYSTEM,
|
||||||
generalGetString(R.string.theme_system)
|
generalGetString(R.string.theme_system)
|
||||||
)
|
)
|
||||||
@@ -49,16 +83,72 @@ object ThemeManager {
|
|||||||
generalGetString(R.string.theme_dark)
|
generalGetString(R.string.theme_dark)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
allThemes.add(
|
||||||
|
Triple(
|
||||||
|
SimplexColorPalette,
|
||||||
|
DefaultTheme.SIMPLEX,
|
||||||
|
generalGetString(R.string.theme_simplex)
|
||||||
|
)
|
||||||
|
)
|
||||||
return allThemes
|
return allThemes
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
|
fun applyTheme(theme: String, darkForSystemTheme: Boolean) {
|
||||||
appPrefs.currentTheme.set(name)
|
appPrefs.currentTheme.set(theme)
|
||||||
CurrentColors.value = currentColors(darkForSystemTheme)
|
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAndApplyPrimaryColor(color: Color) {
|
fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) {
|
||||||
appPrefs.primaryColor.set(color.toArgb())
|
appPrefs.systemDarkTheme.set(theme)
|
||||||
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
|
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndApplyThemeColor(name: ThemeColor, color: Color? = null, darkForSystemTheme: Boolean) {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
|
}
|
||||||
|
var colorToSet = color
|
||||||
|
if (colorToSet == null) {
|
||||||
|
// Setting default color from a base theme
|
||||||
|
colorToSet = when(nonSystemThemeName) {
|
||||||
|
DefaultTheme.LIGHT.name -> name.fromColors(LightColorPalette, LightColorPaletteApp)
|
||||||
|
DefaultTheme.DARK.name -> name.fromColors(DarkColorPalette, DarkColorPaletteApp)
|
||||||
|
DefaultTheme.SIMPLEX.name -> name.fromColors(SimplexColorPalette, SimplexColorPaletteApp)
|
||||||
|
// Will not be here
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
||||||
|
overrides[nonSystemThemeName] = prevValue.withUpdatedColor(name, colorToSet.toReadableHex())
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndApplyThemeData(name: String, theme: ThemeData, darkForSystemTheme: Boolean) {
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
||||||
|
overrides[name] = prevValue.copy(colors = theme.colors)
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetAllThemeColors(darkForSystemTheme: Boolean) {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
|
}
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[nonSystemThemeName] ?: return
|
||||||
|
overrides[nonSystemThemeName] = prevValue.copy(colors = ThemeColors())
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package chat.simplex.app.views
|
package chat.simplex.app.views
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
@@ -11,8 +10,8 @@ import androidx.compose.ui.Modifier
|
|||||||
fun SplashView() {
|
fun SplashView() {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.fillMaxSize(),
|
||||||
.fillMaxSize()
|
color = MaterialTheme.colors.background
|
||||||
) {
|
) {
|
||||||
// Image(
|
// Image(
|
||||||
// painter = painterResource(R.drawable.logo),
|
// painter = painterResource(R.drawable.logo),
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ fun TerminalLayout(
|
|||||||
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = false,
|
allowedVoiceByPrefs = false,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
sendMessage = sendCommand,
|
sendMessage = sendCommand,
|
||||||
sendLiveMessage = null,
|
sendLiveMessage = null,
|
||||||
@@ -98,7 +100,7 @@ fun TerminalLayout(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
TerminalLog(terminalItems)
|
TerminalLog(terminalItems)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.MaterialTheme.colors
|
||||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.focus.*
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
@@ -26,61 +27,72 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
import chat.simplex.app.model.Profile
|
import chat.simplex.app.model.Profile
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.AppBarTitle
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.helpers.withApi
|
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import chat.simplex.app.views.onboarding.ReadableText
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
fun isValidDisplayName(name: String) : Boolean {
|
fun isValidDisplayName(name: String) : Boolean {
|
||||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||||
val displayName = remember { mutableStateOf("") }
|
val displayName = rememberSaveable { mutableStateOf("") }
|
||||||
val fullName = remember { mutableStateOf("") }
|
val fullName = rememberSaveable { mutableStateOf("") }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.create_profile), false)
|
/*CloseSheetBar(close = {
|
||||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
if (chatModel.users.isEmpty()) {
|
||||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||||
Spacer(Modifier.height(10.dp))
|
} else {
|
||||||
Text(
|
close()
|
||||||
stringResource(R.string.display_name),
|
}
|
||||||
style = MaterialTheme.typography.h6,
|
})*/
|
||||||
modifier = Modifier.padding(bottom = 3.dp)
|
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
|
||||||
)
|
AppBarTitle(stringResource(R.string.create_profile))
|
||||||
ProfileNameField(displayName, focusRequester)
|
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues())
|
||||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center)
|
||||||
Text(
|
Spacer(Modifier.height(DEFAULT_PADDING * 1.5f))
|
||||||
errorText,
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
fontSize = 15.sp,
|
Text(
|
||||||
color = MaterialTheme.colors.error
|
stringResource(R.string.display_name),
|
||||||
)
|
fontSize = 16.sp
|
||||||
Spacer(Modifier.height(3.dp))
|
)
|
||||||
Text(
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
stringResource(R.string.full_name_optional__prompt),
|
Text(
|
||||||
style = MaterialTheme.typography.h6,
|
stringResource(R.string.no_spaces),
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
fontSize = 16.sp,
|
||||||
)
|
color = Color.Red
|
||||||
ProfileNameField(fullName)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.full_name_optional__prompt),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
|
)
|
||||||
|
ProfileNameField(fullName, "", ::isValidDisplayName)
|
||||||
|
}
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Row {
|
Row {
|
||||||
if (chatModel.users.isEmpty()) {
|
if (chatModel.users.isEmpty()) {
|
||||||
SimpleButton(
|
SimpleButtonDecorated(
|
||||||
text = stringResource(R.string.about_simplex),
|
text = stringResource(R.string.about_simplex),
|
||||||
icon = Icons.Outlined.ArrowBackIosNew
|
icon = painterResource(R.drawable.ic_arrow_back_ios_new),
|
||||||
|
textDecoration = TextDecoration.None,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
|
||||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
val createModifier: Modifier
|
val createModifier: Modifier
|
||||||
val createColor: Color
|
val createColor: Color
|
||||||
@@ -89,12 +101,12 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
createColor = MaterialTheme.colors.primary
|
createColor = MaterialTheme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
createModifier = Modifier.padding(8.dp)
|
createModifier = Modifier.padding(8.dp)
|
||||||
createColor = HighOrLowlight
|
createColor = MaterialTheme.colors.secondary
|
||||||
}
|
}
|
||||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = createColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,24 +140,50 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
|
||||||
|
var valid by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var focused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val strokeColor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (valid) {
|
||||||
|
if (focused) {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.6f)
|
||||||
|
} else {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.3f)
|
||||||
|
}
|
||||||
|
} else Color.Red
|
||||||
|
}
|
||||||
|
}
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.secondary)
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
.height(40.dp)
|
|
||||||
.clip(RoundedCornerShape(5.dp))
|
|
||||||
.padding(8.dp)
|
|
||||||
.navigationBarsWithImePadding()
|
.navigationBarsWithImePadding()
|
||||||
BasicTextField(
|
.onFocusChanged { focused = it.isFocused }
|
||||||
value = name.value,
|
Box(
|
||||||
onValueChange = { name.value = it },
|
Modifier
|
||||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
.fillMaxWidth()
|
||||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
.height(52.dp)
|
||||||
keyboardOptions = KeyboardOptions(
|
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(50)),
|
||||||
capitalization = KeyboardCapitalization.None,
|
contentAlignment = Alignment.Center
|
||||||
autoCorrect = false
|
) {
|
||||||
),
|
BasicTextField(
|
||||||
singleLine = true,
|
value = name.value,
|
||||||
cursorBrush = SolidColor(HighOrLowlight)
|
onValueChange = { name.value = it },
|
||||||
)
|
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||||
|
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colors.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { name.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,12 +13,14 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||||
with (chatModel) {
|
with (chatModel) {
|
||||||
callInvitations[invitation.contact.id] = invitation
|
callInvitations[invitation.contact.id] = invitation
|
||||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
if (invitation.user.showNotifications) {
|
||||||
activeCallInvitation.value = invitation
|
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||||
controller.ntfManager.notifyCallInvitation(invitation)
|
activeCallInvitation.value = invitation
|
||||||
} else {
|
controller.ntfManager.notifyCallInvitation(invitation)
|
||||||
val contact = invitation.contact
|
} else {
|
||||||
controller.ntfManager.notifyMessageReceived(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
val contact = invitation.contact
|
||||||
|
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package chat.simplex.app.views.call
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.*
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.*
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.*
|
import android.webkit.*
|
||||||
@@ -15,16 +16,15 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -37,7 +37,7 @@ import androidx.webkit.WebViewClientCompat
|
|||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.app.views.helpers.withApi
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
@@ -53,6 +53,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||||
})
|
})
|
||||||
|
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Start service when call happening since it's not already started.
|
// Start service when call happening since it's not already started.
|
||||||
@@ -60,17 +61,48 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
var btDeviceCount = 0
|
||||||
|
val audioCallback = object: AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
|
||||||
|
super.onAudioDevicesAdded(addedDevices)
|
||||||
|
val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
|
btDeviceCount += addedCount
|
||||||
|
audioViaBluetooth.value = btDeviceCount > 0
|
||||||
|
if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||||
|
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||||
|
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
|
||||||
|
super.onAudioDevicesRemoved(removedDevices)
|
||||||
|
val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
|
btDeviceCount -= removedCount
|
||||||
|
audioViaBluetooth.value = btDeviceCount > 0
|
||||||
|
if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||||
|
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||||
|
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
am.registerAudioDeviceCallback(audioCallback, null)
|
||||||
|
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
|
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||||
|
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
proximityLock?.acquire()
|
||||||
onDispose {
|
onDispose {
|
||||||
// Stop it when call ended
|
// Stop it when call ended
|
||||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||||
// Clear selected communication device to default value after we changed it in call
|
dropAudioManagerOverrides()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
am.unregisterAudioDeviceCallback(audioCallback)
|
||||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
proximityLock?.release()
|
||||||
am.clearCommunicationDevice()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cxt = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||||
@@ -100,6 +132,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||||
if (callStatus == WebRTCCallStatus.Connected) {
|
if (callStatus == WebRTCCallStatus.Connected) {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||||
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
@@ -108,8 +141,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
is WCallResponse.Connected -> {
|
is WCallResponse.Connected -> {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(2000L)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
setCallSound(cxt, call)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallResponse.Ended -> {
|
is WCallResponse.Ended -> {
|
||||||
@@ -143,15 +175,18 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
val prevVolumeControlStream = activity.volumeControlStream
|
||||||
|
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
||||||
// Lock orientation to portrait in order to have good experience with calls
|
// Lock orientation to portrait in order to have good experience with calls
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
onDispose {
|
onDispose {
|
||||||
|
activity.volumeControlStream = prevVolumeControlStream
|
||||||
// Unlock orientation
|
// Unlock orientation
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
}
|
}
|
||||||
@@ -159,10 +194,10 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
var cxt = LocalContext.current
|
|
||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = call,
|
call = call,
|
||||||
|
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||||
@@ -171,45 +206,62 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
|||||||
if (call != null) {
|
if (call != null) {
|
||||||
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
||||||
chatModel.activeCall.value = call
|
chatModel.activeCall.value = call
|
||||||
setCallSound(cxt, call)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallSound(cxt: Context, call: Call) {
|
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
Log.d(TAG, "setCallSound: set audio mode")
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||||
if (call.soundSpeaker) {
|
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
am.mode = AudioManager.MODE_NORMAL
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
am.isSpeakerphoneOn = true
|
val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }?.let {
|
if (btDevice != null) {
|
||||||
|
am.setCommunicationDevice(btDevice)
|
||||||
|
} else if (am.communicationDevice?.type != preferredSecondaryDevice) {
|
||||||
|
am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let {
|
||||||
am.setCommunicationDevice(it)
|
am.setCommunicationDevice(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
am.mode = AudioManager.MODE_IN_CALL
|
if (audioViaBluetooth.value) {
|
||||||
am.isSpeakerphoneOn = false
|
am.isSpeakerphoneOn = false
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
am.startBluetoothSco()
|
||||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
|
} else {
|
||||||
am.setCommunicationDevice(it)
|
am.stopBluetoothSco()
|
||||||
}
|
am.isSpeakerphoneOn = speaker
|
||||||
}
|
}
|
||||||
|
am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropAudioManagerOverrides() {
|
||||||
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
am.mode = AudioManager.MODE_NORMAL
|
||||||
|
// Clear selected communication device to default value after we changed it in call
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
am.clearCommunicationDevice()
|
||||||
|
} else {
|
||||||
|
am.isSpeakerphoneOn = false
|
||||||
|
am.stopBluetoothSco()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveCallOverlayLayout(
|
private fun ActiveCallOverlayLayout(
|
||||||
call: Call,
|
call: Call,
|
||||||
|
speakerCanBeEnabled: Boolean,
|
||||||
dismiss: () -> Unit,
|
dismiss: () -> Unit,
|
||||||
toggleAudio: () -> Unit,
|
toggleAudio: () -> Unit,
|
||||||
toggleVideo: () -> Unit,
|
toggleVideo: () -> Unit,
|
||||||
toggleSound: () -> Unit,
|
toggleSound: () -> Unit,
|
||||||
flipCamera: () -> Unit
|
flipCamera: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(DEFAULT_PADDING)) {
|
||||||
when (call.peerMedia ?: call.localMedia) {
|
when (call.peerMedia ?: call.localMedia) {
|
||||||
CallMediaType.Video -> {
|
CallMediaType.Video -> {
|
||||||
CallInfoView(call, alignment = Alignment.Start)
|
CallInfoView(call, alignment = Alignment.Start)
|
||||||
@@ -218,14 +270,14 @@ private fun ActiveCallOverlayLayout(
|
|||||||
ToggleAudioButton(call, toggleAudio)
|
ToggleAudioButton(call, toggleAudio)
|
||||||
Spacer(Modifier.size(40.dp))
|
Spacer(Modifier.size(40.dp))
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
if (call.videoEnabled) {
|
if (call.videoEnabled) {
|
||||||
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
ControlButton(call, painterResource(R.drawable.ic_flip_camera_android_filled), R.string.icon_descr_flip_camera, flipCamera)
|
||||||
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_filled), R.string.icon_descr_video_off, toggleVideo)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_off), R.string.icon_descr_video_on, toggleVideo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,10 +292,10 @@ private fun ActiveCallOverlayLayout(
|
|||||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(start = 32.dp)) {
|
Box(Modifier.padding(start = 32.dp)) {
|
||||||
@@ -251,7 +303,7 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||||
Box(Modifier.padding(end = 32.dp)) {
|
Box(Modifier.padding(end = 32.dp)) {
|
||||||
ToggleSoundButton(call, toggleSound)
|
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,10 +313,10 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
private fun ControlButton(call: Call, icon: Painter, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
|
||||||
if (call.hasMedia) {
|
if (call.hasMedia) {
|
||||||
IconButton(onClick = action) {
|
IconButton(onClick = action, enabled = enabled) {
|
||||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(40.dp))
|
Spacer(Modifier.size(40.dp))
|
||||||
@@ -274,18 +326,18 @@ private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: In
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||||
if (call.audioEnabled) {
|
if (call.audioEnabled) {
|
||||||
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic), R.string.icon_descr_audio_off, toggleAudio)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic_off), R.string.icon_descr_audio_on, toggleAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
|
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||||
if (call.soundSpeaker) {
|
if (call.soundSpeaker) {
|
||||||
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
|
ControlButton(call, painterResource(R.drawable.ic_volume_up), R.string.icon_descr_speaker_off, toggleSound, enabled)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
|
ControlButton(call, painterResource(R.drawable.ic_volume_down), R.string.icon_descr_speaker_on, toggleSound, enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +367,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
// horizontalAlignment = Alignment.CenterHorizontally,
|
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
// modifier = Modifier
|
// modifier = Modifier
|
||||||
// .background(MaterialTheme.colors.background)
|
// .themedBackground()
|
||||||
// .fillMaxSize()
|
// .fillMaxSize()
|
||||||
// ) {
|
// ) {
|
||||||
// WebRTCView(callCommand) { apiMsg ->
|
// WebRTCView(callCommand) { apiMsg ->
|
||||||
@@ -485,6 +537,7 @@ fun PreviewActiveCallOverlayVideo() {
|
|||||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
speakerCanBeEnabled = true,
|
||||||
dismiss = {},
|
dismiss = {},
|
||||||
toggleAudio = {},
|
toggleAudio = {},
|
||||||
toggleVideo = {},
|
toggleVideo = {},
|
||||||
@@ -509,6 +562,7 @@ fun PreviewActiveCallOverlayAudio() {
|
|||||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
speakerCanBeEnabled = true,
|
||||||
dismiss = {},
|
dismiss = {},
|
||||||
toggleAudio = {},
|
toggleAudio = {},
|
||||||
toggleVideo = {},
|
toggleVideo = {},
|
||||||
|
|||||||
+23
-13
@@ -12,19 +12,19 @@ import android.view.WindowManager
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -36,7 +36,6 @@ import chat.simplex.app.model.*
|
|||||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.app.views.onboarding.SimpleXLogo
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
class IncomingCallActivity: ComponentActivity() {
|
class IncomingCallActivity: ComponentActivity() {
|
||||||
@@ -98,7 +97,7 @@ fun IncomingCallActivityView(m: ChatModel) {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
.fillMaxSize()) {
|
.fillMaxSize()) {
|
||||||
if (showCallView) {
|
if (showCallView) {
|
||||||
Box {
|
Box {
|
||||||
@@ -170,24 +169,35 @@ fun IncomingCallLockScreenAlertLayout(
|
|||||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Row {
|
Row {
|
||||||
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
LockScreenCallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
LockScreenCallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
LockScreenCallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||||
SimpleXLogo()
|
SimpleXLogo()
|
||||||
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||||
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
|
SimpleButton(text = stringResource(R.string.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
private fun SimpleXLogo() {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||||
|
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = DEFAULT_PADDING)
|
||||||
|
.fillMaxWidth(0.80f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockScreenCallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
color = Color.Transparent
|
color = Color.Transparent
|
||||||
@@ -201,8 +211,8 @@ private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color,
|
|||||||
IconButton(action) {
|
IconButton(action) {
|
||||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +226,7 @@ fun PreviewIncomingCallLockScreenAlert() {
|
|||||||
SimpleXTheme(true) {
|
SimpleXTheme(true) {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
.fillMaxSize()) {
|
.fillMaxSize()) {
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
invitation = RcvCallInvitation(
|
invitation = RcvCallInvitation(
|
||||||
|
|||||||
+12
-13
@@ -4,15 +4,14 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -52,7 +51,7 @@ fun IncomingCallAlertLayout(
|
|||||||
acceptCall: () -> Unit
|
acceptCall: () -> Unit
|
||||||
) {
|
) {
|
||||||
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||||
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||||
IncomingCallInfo(invitation, chatModel)
|
IncomingCallInfo(invitation, chatModel)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
@@ -60,9 +59,9 @@ fun IncomingCallAlertLayout(
|
|||||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
CallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
CallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
CallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,21 +69,21 @@ fun IncomingCallAlertLayout(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
@Composable fun CallIcon(icon: Painter, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (chatModel.users.size > 1) {
|
if (chatModel.users.size > 1) {
|
||||||
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondary)
|
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(R.drawable.ic_videocam_filled), stringResource(R.string.icon_descr_video_call))
|
||||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
else CallIcon(painterResource(R.drawable.ic_call_filled), stringResource(R.string.icon_descr_audio_call))
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Text(invitation.callTypeText)
|
Text(invitation.callTypeText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
private fun CallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
color = Color.Transparent
|
color = Color.Transparent
|
||||||
@@ -97,7 +96,7 @@ private fun CallButton(text: String, icon: ImageVector, color: Color, action: ()
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package chat.simplex.app.views.chat
|
|||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import InfoRowEllipsis
|
import InfoRowEllipsis
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
@@ -12,9 +13,6 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -22,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -112,7 +111,7 @@ fun ChatInfoView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_contact_question),
|
title = generalGetString(R.string.delete_contact_question),
|
||||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -126,12 +125,13 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() ->
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.clear_chat_question),
|
title = generalGetString(R.string.clear_chat_question),
|
||||||
text = generalGetString(R.string.clear_chat_warning),
|
text = generalGetString(R.string.clear_chat_warning),
|
||||||
confirmText = generalGetString(R.string.clear_verb),
|
confirmText = generalGetString(R.string.clear_verb),
|
||||||
@@ -144,7 +144,8 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +169,7 @@ fun ChatInfoLayout(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState())
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -179,28 +179,25 @@ fun ChatInfoLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||||
|
SectionSpacer()
|
||||||
if (customUserProfile != null) {
|
if (customUserProfile != null) {
|
||||||
SectionSpacer()
|
|
||||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||||
}
|
}
|
||||||
|
SectionDividerSpaced()
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionSpacer()
|
|
||||||
SectionView {
|
SectionView {
|
||||||
if (connectionCode != null) {
|
if (connectionCode != null) {
|
||||||
VerifyCodeButton(contact.verified, verifyClicked)
|
VerifyCodeButton(contact.verified, verifyClicked)
|
||||||
SectionDivider()
|
|
||||||
}
|
}
|
||||||
ContactPreferencesButton(openPreferences)
|
ContactPreferencesButton(openPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionSpacer()
|
SectionDividerSpaced()
|
||||||
|
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
SwitchAddressButton(switchContactAddress)
|
SwitchAddressButton(switchContactAddress)
|
||||||
SectionDivider()
|
|
||||||
if (connStats != null) {
|
if (connStats != null) {
|
||||||
SectionItemView({
|
SectionItemView({
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -211,32 +208,28 @@ fun ChatInfoLayout(
|
|||||||
}
|
}
|
||||||
val rcvServers = connStats.rcvServers
|
val rcvServers = connStats.rcvServers
|
||||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
}
|
}
|
||||||
val sndServers = connStats.sndServers
|
val sndServers = connStats.sndServers
|
||||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced()
|
||||||
SectionView {
|
SectionView {
|
||||||
ClearChatButton(clearChat)
|
ClearChatButton(clearChat)
|
||||||
SectionDivider()
|
|
||||||
DeleteContactButton(deleteContact)
|
DeleteContactButton(deleteContact)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +242,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
|||||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (contact.verified) {
|
if (contact.verified) {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
@@ -290,13 +283,13 @@ fun LocalAliasEditor(
|
|||||||
Text(
|
Text(
|
||||||
generalGetString(R.string.text_field_set_contact_placeholder),
|
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingIcon = if (leadingIcon) {
|
leadingIcon = if (leadingIcon) {
|
||||||
{ Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }
|
{ Icon(painterResource(R.drawable.ic_edit_filled), null, Modifier.padding(start = 7.dp)) }
|
||||||
} else null,
|
} else null,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
focus = focus,
|
focus = focus,
|
||||||
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
||||||
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||||
@@ -331,7 +324,7 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.network_status))
|
Text(stringResource(R.string.network_status))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Info,
|
painterResource(R.drawable.ic_info),
|
||||||
stringResource(R.string.network_status),
|
stringResource(R.string.network_status),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
@@ -343,7 +336,7 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
networkStatus.statusString,
|
networkStatus.statusString,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
ServerImage(networkStatus)
|
ServerImage(networkStatus)
|
||||||
}
|
}
|
||||||
@@ -355,12 +348,12 @@ private fun ServerImage(networkStatus: NetworkStatus) {
|
|||||||
Box(Modifier.size(18.dp)) {
|
Box(Modifier.size(18.dp)) {
|
||||||
when (networkStatus) {
|
when (networkStatus) {
|
||||||
is NetworkStatus.Connected ->
|
is NetworkStatus.Connected ->
|
||||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||||
is NetworkStatus.Disconnected ->
|
is NetworkStatus.Disconnected ->
|
||||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_pending_filled), stringResource(R.string.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary)
|
||||||
is NetworkStatus.Error ->
|
is NetworkStatus.Error ->
|
||||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_error_filled), stringResource(R.string.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary)
|
||||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
else -> Icon(painterResource(R.drawable.ic_circle), stringResource(R.string.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,17 +378,17 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
|
if (contactVerified) painterResource(R.drawable.ic_verified_user) else painterResource(R.drawable.ic_shield),
|
||||||
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
iconColor = HighOrLowlight,
|
iconColor = MaterialTheme.colors.secondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.ToggleOn,
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
stringResource(R.string.contact_preferences),
|
stringResource(R.string.contact_preferences),
|
||||||
click = onClick
|
click = onClick
|
||||||
)
|
)
|
||||||
@@ -404,7 +397,7 @@ private fun ContactPreferencesButton(onClick: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ClearChatButton(onClick: () -> Unit) {
|
fun ClearChatButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Restore,
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
stringResource(R.string.clear_chat_button),
|
stringResource(R.string.clear_chat_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = WarningOrange,
|
textColor = WarningOrange,
|
||||||
@@ -415,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DeleteContactButton(onClick: () -> Unit) {
|
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.button_delete_contact),
|
stringResource(R.string.button_delete_contact),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -430,13 +423,14 @@ private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: C
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.switch_receiving_address_question),
|
title = generalGetString(R.string.switch_receiving_address_question),
|
||||||
text = generalGetString(R.string.switch_receiving_address_desc),
|
text = generalGetString(R.string.switch_receiving_address_desc),
|
||||||
confirmText = generalGetString(R.string.switch_verb),
|
confirmText = generalGetString(R.string.switch_verb),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
switchContactAddress(m, contactId)
|
switchContactAddress(m, contactId)
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import androidx.compose.foundation.lazy.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.mapSaver
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -23,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -152,9 +150,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
|||||||
}
|
}
|
||||||
} else if (chat.chatInfo is ChatInfo.Group) {
|
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||||
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||||
var groupLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||||
|
var groupLink = link?.first
|
||||||
|
var groupLinkMemberRole = link?.second
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
GroupChatInfoView(chatModel, groupLink, { groupLink = it }, close)
|
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
|
||||||
|
groupLink = it.first;
|
||||||
|
groupLinkMemberRole = it.second
|
||||||
|
}, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,27 +196,42 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
|||||||
deleteMessage = { itemId, mode ->
|
deleteMessage = { itemId, mode ->
|
||||||
withApi {
|
withApi {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
val r = chatModel.controller.apiDeleteChatItem(
|
val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId }
|
||||||
type = cInfo.chatType,
|
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
|
||||||
id = cInfo.apiId,
|
val groupInfo = toModerate?.first
|
||||||
itemId = itemId,
|
val groupMember = toModerate?.second
|
||||||
mode = mode
|
val deletedChatItem: ChatItem?
|
||||||
)
|
val toChatItem: ChatItem?
|
||||||
if (r != null) {
|
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||||
val toChatItem = r.toChatItem
|
val r = chatModel.controller.apiDeleteMemberChatItem(
|
||||||
if (toChatItem == null) {
|
groupId = groupInfo.groupId,
|
||||||
chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem)
|
groupMemberId = groupMember.groupMemberId,
|
||||||
} else {
|
itemId = itemId
|
||||||
chatModel.upsertChatItem(cInfo, toChatItem.chatItem)
|
)
|
||||||
}
|
deletedChatItem = r?.first
|
||||||
|
toChatItem = r?.second
|
||||||
|
} else {
|
||||||
|
val r = chatModel.controller.apiDeleteChatItem(
|
||||||
|
type = cInfo.chatType,
|
||||||
|
id = cInfo.apiId,
|
||||||
|
itemId = itemId,
|
||||||
|
mode = mode
|
||||||
|
)
|
||||||
|
deletedChatItem = r?.deletedChatItem?.chatItem
|
||||||
|
toChatItem = r?.toChatItem?.chatItem
|
||||||
|
}
|
||||||
|
if (toChatItem == null && deletedChatItem != null) {
|
||||||
|
chatModel.removeChatItem(cInfo, deletedChatItem)
|
||||||
|
} else if (toChatItem != null) {
|
||||||
|
chatModel.upsertChatItem(cInfo, toChatItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveFile = { fileId ->
|
receiveFile = { fileId ->
|
||||||
val user = chatModel.currentUser.value
|
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||||
if (user != null) {
|
},
|
||||||
withApi { chatModel.controller.receiveFile(user, fileId) }
|
cancelFile = { fileId ->
|
||||||
}
|
withApi { chatModel.controller.cancelFile(user, fileId) }
|
||||||
},
|
},
|
||||||
joinGroup = { groupId ->
|
joinGroup = { groupId ->
|
||||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||||
@@ -293,6 +311,7 @@ fun ChatLayout(
|
|||||||
loadPrevMessages: (ChatInfo) -> Unit,
|
loadPrevMessages: (ChatInfo) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
startCall: (CallMediaType) -> Unit,
|
startCall: (CallMediaType) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
@@ -307,7 +326,7 @@ fun ChatLayout(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
@@ -337,7 +356,7 @@ fun ChatLayout(
|
|||||||
ChatItemsList(
|
ChatItemsList(
|
||||||
chat, unreadCount, composeState, chatItems, searchValue,
|
chat, unreadCount, composeState, chatItems, searchValue,
|
||||||
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||||
receiveFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,7 +376,7 @@ fun ChatInfoToolbar(
|
|||||||
onSearchValueChanged: (String) -> Unit,
|
onSearchValueChanged: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showMenu by rememberSaveable { mutableStateOf(false) }
|
val showMenu = rememberSaveable { mutableStateOf(false) }
|
||||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
val onBackClicked = {
|
val onBackClicked = {
|
||||||
if (!showSearch) {
|
if (!showSearch) {
|
||||||
@@ -371,34 +390,34 @@ fun ChatInfoToolbar(
|
|||||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
|
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), painterResource(R.drawable.ic_search), onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
showSearch = true
|
showSearch = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat.chatInfo is ChatInfo.Direct) {
|
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({
|
IconButton({
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
startCall(CallMediaType.Audio)
|
startCall(CallMediaType.Audio)
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_call_500), stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
|
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), painterResource(R.drawable.ic_videocam), onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
startCall(CallMediaType.Video)
|
startCall(CallMediaType.Video)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({
|
IconButton({
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
addMembers(chat.chatInfo.groupInfo)
|
addMembers(chat.chatInfo.groupInfo)
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_person_add_500), stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,9 +425,9 @@ fun ChatInfoToolbar(
|
|||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||||
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
if (ntfsEnabled.value) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||||
onClick = {
|
onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(200)
|
delay(200)
|
||||||
@@ -419,8 +438,8 @@ fun ChatInfoToolbar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showMenu = true }) {
|
IconButton({ showMenu.value = true }) {
|
||||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
Icon(MoreVertFilled, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,18 +455,14 @@ fun ChatInfoToolbar(
|
|||||||
Divider(Modifier.padding(top = AppBarHeight))
|
Divider(Modifier.padding(top = AppBarHeight))
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false },
|
|
||||||
Modifier.widthIn(min = 220.dp)
|
|
||||||
) {
|
|
||||||
menuItems.forEach { it() }
|
menuItems.forEach { it() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -481,7 +496,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactVerifiedShield() {
|
private fun ContactVerifiedShield() {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||||
@@ -510,6 +525,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
loadPrevMessages: (ChatInfo) -> Unit,
|
loadPrevMessages: (ChatInfo) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||||
@@ -556,6 +572,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
stopListening = true
|
stopListening = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DisposableEffectOnGone(
|
||||||
|
whenGone = {
|
||||||
|
VideoPlayer.releaseAll()
|
||||||
|
}
|
||||||
|
)
|
||||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||||
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -575,10 +596,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (composeState.value.editing) {
|
if (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) {
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
if (composeState.value.editing) {
|
||||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||||
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,11 +641,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(42.dp))
|
Spacer(Modifier.size(42.dp))
|
||||||
}
|
}
|
||||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // direct message
|
} else { // direct message
|
||||||
@@ -633,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
end = if (sent) 12.dp else 76.dp,
|
end = if (sent) 12.dp else 76.dp,
|
||||||
).then(swipeableModifier)
|
).then(swipeableModifier)
|
||||||
) {
|
) {
|
||||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,36 +782,27 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
|||||||
}
|
}
|
||||||
val showButtonWithCounter = topUnreadCount > 0
|
val showButtonWithCounter = topUnreadCount > 0
|
||||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||||
var showDropDown by remember { mutableStateOf(false) }
|
val showDropDown = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
TopEndFloatingButton(
|
TopEndFloatingButton(
|
||||||
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp).align(Alignment.TopEnd),
|
||||||
topUnreadCount,
|
topUnreadCount,
|
||||||
showButtonWithCounter,
|
showButtonWithCounter,
|
||||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||||
onLongClick = { showDropDown = true }
|
onLongClick = { showDropDown.value = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
|
||||||
expanded = showDropDown,
|
ItemAction(
|
||||||
onDismissRequest = { showDropDown = false },
|
generalGetString(R.string.mark_read),
|
||||||
Modifier.width(220.dp),
|
painterResource(R.drawable.ic_check),
|
||||||
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
markRead(
|
markRead(
|
||||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||||
bottomUnreadCount
|
bottomUnreadCount
|
||||||
)
|
)
|
||||||
showDropDown = false
|
showDropDown.value = false
|
||||||
}
|
})
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
generalGetString(R.string.mark_read),
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +899,7 @@ private fun bottomEndFloatingButton(
|
|||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.KeyboardArrowDown,
|
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
@@ -913,21 +927,26 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class ProviderMedia {
|
||||||
|
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
|
||||||
|
data class Video(val uri: Uri, val preview: String): ProviderMedia()
|
||||||
|
}
|
||||||
|
|
||||||
private fun providerForGallery(
|
private fun providerForGallery(
|
||||||
listStateIndex: Int,
|
listStateIndex: Int,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
cItemId: Long,
|
cItemId: Long,
|
||||||
scrollTo: (Int) -> Unit
|
scrollTo: (Int) -> Unit
|
||||||
): ImageGalleryProvider {
|
): ImageGalleryProvider {
|
||||||
fun canShowImage(item: ChatItem): Boolean =
|
fun canShowMedia(item: ChatItem): Boolean =
|
||||||
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
|
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null)
|
||||||
|
|
||||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||||
var processedInternalIndex = -skipInternalIndex.sign
|
var processedInternalIndex = -skipInternalIndex.sign
|
||||||
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
||||||
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
||||||
val item = chatItems[chatItemsIndex]
|
val item = chatItems[chatItemsIndex]
|
||||||
if (canShowImage(item)) {
|
if (canShowMedia(item)) {
|
||||||
processedInternalIndex += skipInternalIndex.sign
|
processedInternalIndex += skipInternalIndex.sign
|
||||||
}
|
}
|
||||||
if (processedInternalIndex == skipInternalIndex) {
|
if (processedInternalIndex == skipInternalIndex) {
|
||||||
@@ -941,16 +960,28 @@ private fun providerForGallery(
|
|||||||
var initialChatId = cItemId
|
var initialChatId = cItemId
|
||||||
return object: ImageGalleryProvider {
|
return object: ImageGalleryProvider {
|
||||||
override val initialIndex: Int = initialIndex
|
override val initialIndex: Int = initialIndex
|
||||||
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
|
override val totalMediaSize = mutableStateOf(Int.MAX_VALUE)
|
||||||
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
|
override fun getMedia(index: Int): ProviderMedia? {
|
||||||
val internalIndex = initialIndex - index
|
val internalIndex = initialIndex - index
|
||||||
val file = item(internalIndex, initialChatId)?.second?.file
|
val item = item(internalIndex, initialChatId)?.second ?: return null
|
||||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
return when (item.content.msgContent) {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
is MsgContent.MCImage -> {
|
||||||
return if (imageBitmap != null && filePath != null) {
|
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file)
|
||||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||||
imageBitmap to uri
|
if (imageBitmap != null && filePath != null) {
|
||||||
} else null
|
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||||
|
ProviderMedia.Image(uri, imageBitmap)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
is MsgContent.MCVideo -> {
|
||||||
|
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||||
|
if (filePath != null) {
|
||||||
|
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||||
|
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun currentPageChanged(index: Int) {
|
override fun currentPageChanged(index: Int) {
|
||||||
@@ -962,7 +993,7 @@ private fun providerForGallery(
|
|||||||
|
|
||||||
override fun scrollToStart() {
|
override fun scrollToStart() {
|
||||||
initialIndex = 0
|
initialIndex = 0
|
||||||
initialChatId = chatItems.first { canShowImage(it) }.id
|
initialChatId = chatItems.first { canShowMedia(it) }.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDismiss(index: Int) {
|
override fun onDismiss(index: Int) {
|
||||||
@@ -1040,6 +1071,7 @@ fun PreviewChatLayout() {
|
|||||||
loadPrevMessages = { _ -> },
|
loadPrevMessages = { _ -> },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
@@ -1099,6 +1131,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
loadPrevMessages = { _ -> },
|
loadPrevMessages = { _ -> },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.height(60.dp)
|
.height(60.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(SentColorLight),
|
.background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 4.dp, end = 2.dp)
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
@@ -37,7 +36,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
|||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
|
|||||||
@@ -4,26 +4,28 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
import chat.simplex.app.views.helpers.UploadContent
|
||||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(SentColorLight),
|
.background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -31,19 +33,38 @@ fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabl
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
||||||
) {
|
) {
|
||||||
items(images.size) { index ->
|
itemsIndexed(media.images) { index, item ->
|
||||||
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
|
val content = media.content[index]
|
||||||
Image(
|
if (content is UploadContent.Video) {
|
||||||
imageBitmap,
|
Box(contentAlignment = Alignment.Center) {
|
||||||
"preview image",
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
Image(
|
||||||
)
|
imageBitmap,
|
||||||
|
"preview video",
|
||||||
|
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_videocam_filled),
|
||||||
|
"preview video",
|
||||||
|
Modifier
|
||||||
|
.size(20.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
|
Image(
|
||||||
|
imageBitmap,
|
||||||
|
"preview image",
|
||||||
|
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelImages) {
|
IconButton(onClick = cancelImages) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,23 +7,18 @@ import android.Manifest
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.*
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.graphics.ImageDecoder.DecodeException
|
|
||||||
import android.graphics.drawable.AnimatedImageDrawable
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.webkit.MimeTypeMap
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.Reply
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -31,13 +26,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -50,7 +45,7 @@ import java.nio.file.Files
|
|||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
@Serializable object NoPreview: ComposePreview()
|
@Serializable object NoPreview: ComposePreview()
|
||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class ImagePreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||||
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||||
}
|
}
|
||||||
@@ -96,7 +91,7 @@ data class ComposeState(
|
|||||||
val sendEnabled: () -> Boolean
|
val sendEnabled: () -> Boolean
|
||||||
get() = {
|
get() = {
|
||||||
val hasContent = when (preview) {
|
val hasContent = when (preview) {
|
||||||
is ComposePreview.ImagePreview -> true
|
is ComposePreview.MediaPreview -> true
|
||||||
is ComposePreview.VoicePreview -> true
|
is ComposePreview.VoicePreview -> true
|
||||||
is ComposePreview.FilePreview -> true
|
is ComposePreview.FilePreview -> true
|
||||||
else -> message.isNotEmpty() || liveMessage != null
|
else -> message.isNotEmpty() || liveMessage != null
|
||||||
@@ -109,7 +104,7 @@ data class ComposeState(
|
|||||||
val linkPreviewAllowed: Boolean
|
val linkPreviewAllowed: Boolean
|
||||||
get() =
|
get() =
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.MediaPreview -> false
|
||||||
is ComposePreview.VoicePreview -> false
|
is ComposePreview.VoicePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> useLinkPreviews
|
else -> useLinkPreviews
|
||||||
@@ -159,7 +154,8 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
|||||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||||
// TODO: include correct type
|
// TODO: include correct type
|
||||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||||
|
is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||||
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
|
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
|
||||||
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
|
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
|
||||||
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||||
@@ -180,14 +176,16 @@ fun ComposeView(
|
|||||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
|
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
if (bitmap != null) {
|
||||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
@@ -197,50 +195,56 @@ fun ComposeView(
|
|||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val processPickedImage = { uris: List<Uri>, text: String? ->
|
val processPickedMedia = { uris: List<Uri>, text: String? ->
|
||||||
val content = ArrayList<UploadContent>()
|
val content = ArrayList<UploadContent>()
|
||||||
val imagesPreview = ArrayList<String>()
|
val imagesPreview = ArrayList<String>()
|
||||||
uris.forEach { uri ->
|
uris.forEach { uri ->
|
||||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
var bitmap: Bitmap? = null
|
||||||
val drawable = try {
|
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true
|
||||||
ImageDecoder.decodeDrawable(source)
|
when {
|
||||||
} catch (e: DecodeException) {
|
isImage -> {
|
||||||
AlertManager.shared.showAlertMsg(
|
// Image
|
||||||
title = generalGetString(R.string.image_decoding_exception_title),
|
val drawable = getDrawableFromUri(uri)
|
||||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
|
||||||
)
|
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||||
null
|
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||||
}
|
if (isAnimNewApi || isAnimOldApi) {
|
||||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
// It's a gif or webp
|
||||||
if (drawable is AnimatedImageDrawable) {
|
val fileSize = getFileSize(context, uri)
|
||||||
// It's a gif or webp
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
val fileSize = getFileSize(context, uri)
|
content.add(UploadContent.AnimatedImage(uri))
|
||||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
} else {
|
||||||
content.add(UploadContent.AnimatedImage(uri))
|
bitmap = null
|
||||||
} else {
|
AlertManager.shared.showAlertMsg(
|
||||||
bitmap = null
|
generalGetString(R.string.large_file),
|
||||||
AlertManager.shared.showAlertMsg(
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
generalGetString(R.string.large_file),
|
)
|
||||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
}
|
||||||
)
|
} else {
|
||||||
|
content.add(UploadContent.SimpleImage(uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Video
|
||||||
|
val res = getBitmapFromVideo(uri)
|
||||||
|
bitmap = res.preview
|
||||||
|
val durationMs = res.duration
|
||||||
|
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
content.add(UploadContent.SimpleImage(uri))
|
|
||||||
}
|
}
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imagesPreview.isNotEmpty()) {
|
if (imagesPreview.isNotEmpty()) {
|
||||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content))
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val processPickedFile = { uri: Uri?, text: String? ->
|
val processPickedFile = { uri: Uri?, text: String? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val fileSize = getFileSize(context, uri)
|
val fileSize = getFileSize(context, uri)
|
||||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
val fileName = getFileName(SimplexApp.context, uri)
|
val fileName = getFileName(SimplexApp.context, uri)
|
||||||
if (fileName != null) {
|
if (fileName != null) {
|
||||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||||
@@ -248,19 +252,22 @@ fun ComposeView(
|
|||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
|
||||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
|
||||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||||
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||||
|
|
||||||
LaunchedEffect(attachmentOption.value) {
|
LaunchedEffect(attachmentOption.value) {
|
||||||
when (attachmentOption.value) {
|
when (attachmentOption.value) {
|
||||||
AttachmentOption.TakePhoto -> {
|
AttachmentOption.CameraPhoto -> {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launchWithFallback()
|
cameraLauncher.launchWithFallback()
|
||||||
@@ -271,15 +278,23 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
AttachmentOption.PickImage -> {
|
AttachmentOption.GalleryImage -> {
|
||||||
try {
|
try {
|
||||||
galleryLauncher.launch(0)
|
galleryImageLauncher.launch(0)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
galleryLauncherFallback.launch("image/*")
|
galleryImageLauncherFallback.launch("image/*")
|
||||||
}
|
}
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
AttachmentOption.PickFile -> {
|
AttachmentOption.GalleryVideo -> {
|
||||||
|
try {
|
||||||
|
galleryVideoLauncher.launch(0)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
galleryVideoLauncherFallback.launch("video/*")
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.File -> {
|
||||||
filesLauncher.launch("*/*")
|
filesLauncher.launch("*/*")
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
@@ -396,6 +411,7 @@ fun ComposeView(
|
|||||||
is MsgContent.MCText -> checkLinkPreview()
|
is MsgContent.MCText -> checkLinkPreview()
|
||||||
is MsgContent.MCLink -> checkLinkPreview()
|
is MsgContent.MCLink -> checkLinkPreview()
|
||||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||||
|
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
|
||||||
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
||||||
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
||||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
||||||
@@ -435,15 +451,20 @@ fun ComposeView(
|
|||||||
when (val preview = cs.preview) {
|
when (val preview = cs.preview) {
|
||||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||||
is ComposePreview.ImagePreview -> {
|
is ComposePreview.MediaPreview -> {
|
||||||
preview.content.forEachIndexed { index, it ->
|
preview.content.forEachIndexed { index, it ->
|
||||||
val file = when (it) {
|
val file = when (it) {
|
||||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||||
|
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||||
}
|
}
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
files.add(file)
|
files.add(file)
|
||||||
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
if (it is UploadContent.Video) {
|
||||||
|
msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration))
|
||||||
|
} else {
|
||||||
|
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,7 +498,11 @@ fun ComposeView(
|
|||||||
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) {
|
if (sent == null &&
|
||||||
|
(cs.preview is ComposePreview.MediaPreview ||
|
||||||
|
cs.preview is ComposePreview.FilePreview ||
|
||||||
|
cs.preview is ComposePreview.VoicePreview)
|
||||||
|
) {
|
||||||
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,8 +624,8 @@ fun ComposeView(
|
|||||||
when (val preview = composeState.value.preview) {
|
when (val preview = composeState.value.preview) {
|
||||||
ComposePreview.NoPreview -> {}
|
ComposePreview.NoPreview -> {}
|
||||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
is ComposePreview.MediaPreview -> ComposeImageView(
|
||||||
preview.images,
|
preview,
|
||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
@@ -623,10 +648,10 @@ fun ComposeView(
|
|||||||
fun contextItemView() {
|
fun contextItemView() {
|
||||||
when (val contextItem = composeState.value.contextItem) {
|
when (val contextItem = composeState.value.contextItem) {
|
||||||
ComposeContextItem.NoContextItem -> {}
|
ComposeContextItem.NoContextItem -> {}
|
||||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
|
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_reply)) {
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||||
}
|
}
|
||||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
|
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_edit_filled)) {
|
||||||
clearState()
|
clearState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -638,13 +663,16 @@ fun ComposeView(
|
|||||||
|
|
||||||
when (val shared = chatModel.sharedContent.value) {
|
when (val shared = chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> onMessageChange(shared.text)
|
is SharedContent.Text -> onMessageChange(shared.text)
|
||||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
is SharedContent.Media -> processPickedMedia(shared.uris, shared.text)
|
||||||
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
chatModel.sharedContent.value = null
|
chatModel.sharedContent.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||||
|
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
@@ -656,11 +684,11 @@ fun ComposeView(
|
|||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled) {
|
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AttachFile,
|
painterResource(R.drawable.ic_attach_file_filled_500),
|
||||||
contentDescription = stringResource(R.string.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (!composeState.value.attachmentDisabled) MaterialTheme.colors.primary else HighOrLowlight,
|
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
@@ -698,6 +726,13 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||||
|
if (!chat.userCanSend) {
|
||||||
|
clearCurrentDraft()
|
||||||
|
clearState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val activity = LocalContext.current as Activity
|
val activity = LocalContext.current as Activity
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val orientation = activity.resources.configuration.orientation
|
val orientation = activity.resources.configuration.orientation
|
||||||
@@ -733,6 +768,8 @@ fun ComposeView(
|
|||||||
needToAllowVoiceToContact,
|
needToAllowVoiceToContact,
|
||||||
allowedVoiceByPrefs,
|
allowedVoiceByPrefs,
|
||||||
allowVoiceToContact = ::allowVoiceToContact,
|
allowVoiceToContact = ::allowVoiceToContact,
|
||||||
|
userIsObserver = userIsObserver.value,
|
||||||
|
userCanSend = userCanSend.value,
|
||||||
sendMessage = {
|
sendMessage = {
|
||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
@@ -759,7 +796,7 @@ class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
|||||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||||
}
|
}
|
||||||
|
|
||||||
class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
override fun createIntent(context: Context, input: Int) =
|
override fun createIntent(context: Context, input: Int) =
|
||||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
@@ -784,3 +821,30 @@ class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
|||||||
else
|
else
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
|
override fun createIntent(context: Context, input: Int) =
|
||||||
|
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
type = "video/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||||
|
if (intent?.data != null)
|
||||||
|
listOf(intent.data!!)
|
||||||
|
else if (intent?.clipData != null)
|
||||||
|
with(intent.clipData!!) {
|
||||||
|
val uris = ArrayList<Uri>()
|
||||||
|
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||||
|
val uri = getItemAt(i).uri
|
||||||
|
if (uri != null) uris.add(uri)
|
||||||
|
}
|
||||||
|
if (itemCount > 10) {
|
||||||
|
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||||
|
}
|
||||||
|
uris
|
||||||
|
}
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import androidx.compose.animation.core.Animatable
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -16,7 +14,6 @@ import androidx.compose.ui.unit.sp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.durationText
|
import chat.simplex.app.model.durationText
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
@@ -59,12 +56,13 @@ fun ComposeVoiceView(
|
|||||||
.height(3.dp)
|
.height(3.dp)
|
||||||
.background(MaterialTheme.colors.primary)
|
.background(MaterialTheme.colors.primary)
|
||||||
)
|
)
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.height(60.dp)
|
.height(60.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(SentColorLight),
|
.background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -77,12 +75,12 @@ fun ComposeVoiceView(
|
|||||||
},
|
},
|
||||||
enabled = finishedRecording) {
|
enabled = finishedRecording) {
|
||||||
Icon(
|
Icon(
|
||||||
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 4.dp, end = 2.dp)
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
.size(36.dp),
|
.size(36.dp),
|
||||||
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
|
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val numberInText = remember(recordedDurationMs, progress.value) {
|
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||||
@@ -97,7 +95,7 @@ fun ComposeVoiceView(
|
|||||||
Text(
|
Text(
|
||||||
durationText(numberInText.value),
|
durationText(numberInText.value),
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
@@ -109,7 +107,7 @@ fun ComposeVoiceView(
|
|||||||
modifier = Modifier.padding(0.dp)
|
modifier = Modifier.padding(0.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -12,7 +12,6 @@ import androidx.compose.material.MaterialTheme
|
|||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -50,7 +49,6 @@ fun ContactPreferencesView(
|
|||||||
if (featuresAllowed == currentFeaturesAllowed) close()
|
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
},
|
},
|
||||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
|
||||||
) {
|
) {
|
||||||
ContactPreferencesLayout(
|
ContactPreferencesLayout(
|
||||||
featuresAllowed,
|
featuresAllowed,
|
||||||
@@ -81,34 +79,38 @@ private fun ContactPreferencesLayout(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState()),
|
||||||
.padding(bottom = DEFAULT_PADDING),
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.contact_preferences))
|
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||||
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||||
val onTTLUpdated = { ttl: Int? ->
|
val onTTLUpdated = { ttl: Int? ->
|
||||||
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400))
|
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
||||||
}
|
}
|
||||||
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
||||||
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||||
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||||
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||||
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||||
applyPrefs(featuresAllowed.copy(voice = it))
|
applyPrefs(featuresAllowed.copy(voice = it))
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowCalls: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) }
|
||||||
|
FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) {
|
||||||
|
applyPrefs(featuresAllowed.copy(calls = it))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
ResetSaveButtons(
|
ResetSaveButtons(
|
||||||
reset = reset,
|
reset = reset,
|
||||||
save = savePrefs,
|
save = savePrefs,
|
||||||
disabled = featuresAllowed == currentFeaturesAllowed
|
disabled = featuresAllowed == currentFeaturesAllowed
|
||||||
)
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,26 +130,24 @@ private fun FeatureSection(
|
|||||||
|
|
||||||
SectionView(
|
SectionView(
|
||||||
feature.text.uppercase(),
|
feature.text.uppercase(),
|
||||||
icon = feature.iconFilled,
|
icon = feature.iconFilled(),
|
||||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||||
leadingIcon = true,
|
leadingIcon = true,
|
||||||
) {
|
) {
|
||||||
SectionItemView {
|
ExposedDropDownSettingRow(
|
||||||
ExposedDropDownSettingRow(
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
generalGetString(R.string.chat_preferences_you_allow),
|
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||||
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
allowFeature,
|
||||||
allowFeature,
|
icon = null,
|
||||||
icon = null,
|
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||||
onSelected = onSelected
|
onSelected = onSelected
|
||||||
)
|
)
|
||||||
}
|
|
||||||
SectionDivider()
|
|
||||||
InfoRow(
|
InfoRow(
|
||||||
generalGetString(R.string.chat_preferences_contact_allows),
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
pref.contactPreference.allow.text
|
pref.contactPreference.allow.text
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SectionTextFooter(feature.enabledDescription(enabled))
|
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -166,24 +166,20 @@ private fun TimedMessagesFeatureSection(
|
|||||||
|
|
||||||
SectionView(
|
SectionView(
|
||||||
ChatFeature.TimedMessages.text.uppercase(),
|
ChatFeature.TimedMessages.text.uppercase(),
|
||||||
icon = ChatFeature.TimedMessages.iconFilled,
|
icon = ChatFeature.TimedMessages.iconFilled(),
|
||||||
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||||
leadingIcon = true,
|
leadingIcon = true,
|
||||||
) {
|
) {
|
||||||
SectionItemView {
|
PreferenceToggle(
|
||||||
PreferenceToggle(
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
generalGetString(R.string.chat_preferences_you_allow),
|
checked = allowFeature.value,
|
||||||
checked = allowFeature.value,
|
) { allow ->
|
||||||
) { allow ->
|
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||||
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
InfoRow(
|
InfoRow(
|
||||||
generalGetString(R.string.chat_preferences_contact_allows),
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
pref.contactPreference.allow.text
|
pref.contactPreference.allow.text
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
if (featuresAllowed.timedMessagesAllowed) {
|
if (featuresAllowed.timedMessagesAllowed) {
|
||||||
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
@@ -198,11 +194,10 @@ private fun TimedMessagesFeatureSection(
|
|||||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
SectionView {
|
SectionView {
|
||||||
SectionItemView(reset, disabled = disabled) {
|
SectionItemView(reset, disabled = disabled) {
|
||||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
SectionItemView(save, disabled = disabled) {
|
SectionItemView(save, disabled = disabled) {
|
||||||
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,14 +206,12 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool
|
|||||||
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||||
val ttlValues = TimedMessagesPreference.ttlValues
|
val ttlValues = TimedMessagesPreference.ttlValues
|
||||||
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||||
SectionItemView {
|
ExposedDropDownSettingRow(
|
||||||
ExposedDropDownSettingRow(
|
generalGetString(R.string.delete_after),
|
||||||
generalGetString(R.string.delete_after),
|
values.map { it to TimedMessagesPreference.ttlText(it) },
|
||||||
values.map { it to TimedMessagesPreference.ttlText(it) },
|
selection,
|
||||||
selection,
|
onSelected = onSelected
|
||||||
onSelected = onSelected
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
|||||||
@@ -3,34 +3,34 @@ package chat.simplex.app.views.chat
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContextItemView(
|
fun ContextItemView(
|
||||||
contextItem: ChatItem,
|
contextItem: ChatItem,
|
||||||
contextIcon: ImageVector,
|
contextIcon: Painter,
|
||||||
cancelContextItem: () -> Unit
|
cancelContextItem: () -> Unit
|
||||||
) {
|
) {
|
||||||
val sent = contextItem.chatDir.sent
|
val sent = contextItem.chatDir.sent
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
.background(if (sent) sentColor else receivedColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -47,7 +47,7 @@ fun ContextItemView(
|
|||||||
.height(20.dp)
|
.height(20.dp)
|
||||||
.width(20.dp),
|
.width(20.dp),
|
||||||
contentDescription = stringResource(R.string.icon_descr_context),
|
contentDescription = stringResource(R.string.icon_descr_context),
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
)
|
)
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
contextItem.text, contextItem.formattedText,
|
contextItem.text, contextItem.formattedText,
|
||||||
@@ -58,7 +58,7 @@ fun ContextItemView(
|
|||||||
}
|
}
|
||||||
IconButton(onClick = cancelContextItem) {
|
IconButton(onClick = cancelContextItem) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.cancel_verb),
|
contentDescription = stringResource(R.string.cancel_verb),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
@@ -73,8 +73,7 @@ fun PreviewContextItemView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||||
contextIcon = Icons.Filled.Edit,
|
contextIcon = painterResource(R.drawable.ic_edit_filled)
|
||||||
cancelContextItem = {}
|
) {}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,18 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.*
|
import android.view.inputmethod.*
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -28,8 +27,9 @@ import androidx.compose.ui.*
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -44,12 +44,13 @@ import androidx.core.widget.*
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.chat.item.ItemAction
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
@@ -60,6 +61,8 @@ fun SendMsgView(
|
|||||||
liveMessageAlertShown: SharedPreference<Boolean>,
|
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||||
needToAllowVoiceToContact: Boolean,
|
needToAllowVoiceToContact: Boolean,
|
||||||
allowedVoiceByPrefs: Boolean,
|
allowedVoiceByPrefs: Boolean,
|
||||||
|
userIsObserver: Boolean,
|
||||||
|
userCanSend: Boolean,
|
||||||
allowVoiceToContact: () -> Unit,
|
allowVoiceToContact: () -> Unit,
|
||||||
sendMessage: () -> Unit,
|
sendMessage: () -> Unit,
|
||||||
sendLiveMessage: (suspend () -> Unit)? = null,
|
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||||
@@ -70,14 +73,22 @@ fun SendMsgView(
|
|||||||
) {
|
) {
|
||||||
Box(Modifier.padding(vertical = 8.dp)) {
|
Box(Modifier.padding(vertical = 8.dp)) {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
|
||||||
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||||
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||||
NativeKeyboard(composeState, textStyle, showDeleteTextButton, onMessageChange)
|
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||||
// Disable clicks on text field
|
// Disable clicks on text field
|
||||||
if (cs.preview is ComposePreview.VoicePreview) {
|
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
|
||||||
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
|
Box(Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.observer_cant_send_message_title),
|
||||||
|
text = generalGetString(R.string.observer_cant_send_message_desc)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (showDeleteTextButton.value) {
|
if (showDeleteTextButton.value) {
|
||||||
DeleteTextButton(composeState)
|
DeleteTextButton(composeState)
|
||||||
@@ -99,11 +110,11 @@ fun SendMsgView(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||||
when {
|
when {
|
||||||
needToAllowVoiceToContact || !allowedVoiceByPrefs -> {
|
needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
|
||||||
DisallowedVoiceButton {
|
DisallowedVoiceButton(userCanSend) {
|
||||||
if (needToAllowVoiceToContact) {
|
if (needToAllowVoiceToContact) {
|
||||||
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||||
} else {
|
} else if (!allowedVoiceByPrefs) {
|
||||||
showDisabledVoiceAlert(isDirectChat)
|
showDisabledVoiceAlert(isDirectChat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +129,7 @@ fun SendMsgView(
|
|||||||
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||||
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
StartLiveMessageButton {
|
StartLiveMessageButton(userCanSend) {
|
||||||
if (composeState.value.preview is ComposePreview.NoPreview) {
|
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
}
|
}
|
||||||
@@ -133,7 +144,7 @@ fun SendMsgView(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
val icon = if (cs.editing || cs.liveMessage != null) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward)
|
||||||
val disabled = !cs.sendEnabled() ||
|
val disabled = !cs.sendEnabled() ||
|
||||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||||
cs.endLiveDisabled
|
cs.endLiveDisabled
|
||||||
@@ -142,20 +153,18 @@ fun SendMsgView(
|
|||||||
cs.contextItem is ComposeContextItem.NoContextItem &&
|
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||||
sendLiveMessage != null && updateLiveMessage != null
|
sendLiveMessage != null && updateLiveMessage != null
|
||||||
) {
|
) {
|
||||||
var showDropdown by rememberSaveable { mutableStateOf(false) }
|
val showDropdown = rememberSaveable { mutableStateOf(false) }
|
||||||
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown = true }
|
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
|
||||||
|
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(
|
||||||
expanded = showDropdown,
|
showDropdown,
|
||||||
onDismissRequest = { showDropdown = false },
|
|
||||||
Modifier.width(220.dp),
|
|
||||||
) {
|
) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
generalGetString(R.string.send_live_message),
|
generalGetString(R.string.send_live_message),
|
||||||
Icons.Filled.Bolt,
|
BoltFilled,
|
||||||
onClick = {
|
onClick = {
|
||||||
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
showDropdown = false
|
showDropdown.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,11 +182,12 @@ private fun NativeKeyboard(
|
|||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
textStyle: MutableState<TextStyle>,
|
textStyle: MutableState<TextStyle>,
|
||||||
showDeleteTextButton: MutableState<Boolean>,
|
showDeleteTextButton: MutableState<Boolean>,
|
||||||
|
userIsObserver: Boolean,
|
||||||
onMessageChange: (String) -> Unit
|
onMessageChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
val textColor = MaterialTheme.colors.onBackground
|
val textColor = MaterialTheme.colors.onBackground
|
||||||
val tintColor = MaterialTheme.colors.secondary
|
val tintColor = MaterialTheme.colors.secondaryVariant
|
||||||
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
@@ -213,7 +223,7 @@ private fun NativeKeyboard(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return@OnCommitContentListener false
|
return@OnCommitContentListener false
|
||||||
}
|
}
|
||||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||||
@@ -229,7 +239,17 @@ private fun NativeKeyboard(
|
|||||||
editText.background = drawable
|
editText.background = drawable
|
||||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||||
editText.setText(cs.message)
|
editText.setText(cs.message)
|
||||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||||
|
f.isAccessible = true
|
||||||
|
f.set(editText, R.drawable.edit_text_cursor)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||||
editText
|
editText
|
||||||
@@ -253,22 +273,29 @@ private fun NativeKeyboard(
|
|||||||
showDeleteTextButton.value = it.lineCount >= 4
|
showDeleteTextButton.value = it.lineCount >= 4
|
||||||
}
|
}
|
||||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
Text(
|
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
|
||||||
generalGetString(R.string.voice_message_send_text),
|
} else if (userIsObserver) {
|
||||||
Modifier.padding(padding),
|
ComposeOverlay(R.string.you_are_observer, textStyle, padding)
|
||||||
color = HighOrLowlight,
|
|
||||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||||
|
Text(
|
||||||
|
generalGetString(textId),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
|
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
|
||||||
IconButton(
|
IconButton(
|
||||||
{ composeState.value = composeState.value.copy(message = "") },
|
{ composeState.value = composeState.value.copy(message = "") },
|
||||||
Modifier.align(Alignment.TopEnd).size(36.dp)
|
Modifier.align(Alignment.TopEnd).size(36.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Close, null, Modifier.padding(7.dp).size(36.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,12 +349,12 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.KeyboardVoice,
|
painterResource(R.drawable.ic_keyboard_voice),
|
||||||
stringResource(R.string.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
@@ -339,7 +366,7 @@ private fun DisallowedVoiceButton(onClick: () -> Unit) {
|
|||||||
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.KeyboardVoice,
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
stringResource(R.string.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -371,7 +398,7 @@ private fun LockToCurrentOrientationUntilDispose() {
|
|||||||
private fun StopRecordButton(onClick: () -> Unit) {
|
private fun StopRecordButton(onClick: () -> Unit) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Stop,
|
painterResource(R.drawable.ic_stop_filled),
|
||||||
stringResource(R.string.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -385,7 +412,7 @@ private fun StopRecordButton(onClick: () -> Unit) {
|
|||||||
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||||
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.KeyboardVoice,
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
stringResource(R.string.icon_descr_record_voice_message),
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -397,7 +424,7 @@ private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProgressIndicator() {
|
private fun ProgressIndicator() {
|
||||||
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
|
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -406,7 +433,7 @@ private fun CancelLiveMessageButton(
|
|||||||
) {
|
) {
|
||||||
IconButton(onClick, Modifier.size(36.dp)) {
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
stringResource(R.string.icon_descr_cancel_live_message),
|
stringResource(R.string.icon_descr_cancel_live_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -418,7 +445,7 @@ private fun CancelLiveMessageButton(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendMsgButton(
|
private fun SendMsgButton(
|
||||||
icon: ImageVector,
|
icon: Painter,
|
||||||
sizeDp: Animatable<Float, AnimationVector1D>,
|
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||||
alpha: Animatable<Float, AnimationVector1D>,
|
alpha: Animatable<Float, AnimationVector1D>,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
@@ -447,20 +474,20 @@ private fun SendMsgButton(
|
|||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.alpha(alpha.value)
|
.alpha(alpha.value)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (enabled) MaterialTheme.colors.primary else HighOrLowlight)
|
.background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
|
||||||
.padding(3.dp)
|
.padding(3.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StartLiveMessageButton(onClick: () -> Unit) {
|
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.requiredSize(36.dp)
|
modifier = Modifier.requiredSize(36.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = true,
|
enabled = enabled,
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = rememberRipple(bounded = false, radius = 24.dp)
|
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||||
@@ -468,9 +495,9 @@ private fun StartLiveMessageButton(onClick: () -> Unit) {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Bolt,
|
BoltFilled,
|
||||||
stringResource(R.string.icon_descr_send_message),
|
stringResource(R.string.icon_descr_send_message),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
@@ -571,6 +598,8 @@ fun PreviewSendMsgView() {
|
|||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
@@ -599,6 +628,8 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
@@ -627,6 +658,8 @@ fun PreviewSendMsgViewInProgress() {
|
|||||||
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
needToAllowVoiceToContact = false,
|
needToAllowVoiceToContact = false,
|
||||||
allowedVoiceByPrefs = true,
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
allowVoiceToContact = {},
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -66,7 +65,7 @@ private fun VerifyCodeLayout(
|
|||||||
val splitCode = splitToParts(connectionCode, 24)
|
val splitCode = splitToParts(connectionCode, 24)
|
||||||
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||||
if (connectionVerified) {
|
if (connectionVerified) {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
||||||
Text(String.format(stringResource(R.string.is_verified), displayName))
|
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||||
} else {
|
} else {
|
||||||
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||||
@@ -90,7 +89,7 @@ private fun VerifyCodeLayout(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Box(Modifier.weight(1f)) {
|
Box(Modifier.weight(1f)) {
|
||||||
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||||
Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
@@ -106,16 +105,16 @@ private fun VerifyCodeLayout(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
if (connectionVerified) {
|
if (connectionVerified) {
|
||||||
SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) {
|
SimpleButton(generalGetString(R.string.clear_verification), painterResource(R.drawable.ic_shield)) {
|
||||||
verifyCode(null) {}
|
verifyCode(null) {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) {
|
SimpleButton(generalGetString(R.string.scan_code), painterResource(R.drawable.ic_qr_code)) {
|
||||||
ModalManager.shared.showModal {
|
ModalManager.shared.showModal {
|
||||||
ScanCodeView(verifyCode) { }
|
ScanCodeView(verifyCode) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) {
|
SimpleButton(generalGetString(R.string.mark_code_verified), painterResource(R.drawable.ic_verified_user)) {
|
||||||
verifyCode(connectionCode) { verified ->
|
verifyCode(connectionCode) { verified ->
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -126,6 +125,7 @@ private fun VerifyCodeLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-34
@@ -1,7 +1,8 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionCustomFooter
|
import SectionCustomFooter
|
||||||
import SectionDivider
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
@@ -9,15 +10,12 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.TheaterComedy
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -28,6 +26,7 @@ import chat.simplex.app.model.*
|
|||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.InfoAboutIncognito
|
||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,6 +36,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
|
|||||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModel.incognito.value,
|
||||||
groupInfo = groupInfo,
|
groupInfo = groupInfo,
|
||||||
creatingGroup = creatingGroup,
|
creatingGroup = creatingGroup,
|
||||||
contactsToAdd = getContactsToAdd(chatModel),
|
contactsToAdd = getContactsToAdd(chatModel),
|
||||||
@@ -85,6 +85,7 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersLayout(
|
fun AddGroupMembersLayout(
|
||||||
|
chatModelIncognito: Boolean,
|
||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
creatingGroup: Boolean,
|
creatingGroup: Boolean,
|
||||||
contactsToAdd: List<Contact>,
|
contactsToAdd: List<Contact>,
|
||||||
@@ -102,9 +103,16 @@ fun AddGroupMembersLayout(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.button_add_members))
|
AppBarTitle(stringResource(R.string.button_add_members))
|
||||||
|
InfoAboutIncognito(
|
||||||
|
chatModelIncognito,
|
||||||
|
false,
|
||||||
|
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||||
|
generalGetString(R.string.group_main_profile_sent),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
@@ -125,7 +133,7 @@ fun AddGroupMembersLayout(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_contacts_to_add),
|
stringResource(R.string.no_contacts_to_add),
|
||||||
Modifier.padding(),
|
Modifier.padding(),
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -134,12 +142,8 @@ fun AddGroupMembersLayout(
|
|||||||
SectionItemView(openPreferences) {
|
SectionItemView(openPreferences) {
|
||||||
Text(stringResource(R.string.set_group_preferences))
|
Text(stringResource(R.string.set_group_preferences))
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
}
|
}
|
||||||
SectionItemView {
|
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
|
||||||
}
|
|
||||||
SectionDivider()
|
|
||||||
if (creatingGroup && selectedContacts.isEmpty()) {
|
if (creatingGroup && selectedContacts.isEmpty()) {
|
||||||
SkipInvitingButton(close)
|
SkipInvitingButton(close)
|
||||||
} else {
|
} else {
|
||||||
@@ -149,13 +153,13 @@ fun AddGroupMembersLayout(
|
|||||||
SectionCustomFooter {
|
SectionCustomFooter {
|
||||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(stringResource(R.string.select_contacts)) {
|
SectionView(stringResource(R.string.select_contacts)) {
|
||||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,16 +176,15 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||||||
values,
|
values,
|
||||||
selectedRole,
|
selectedRole,
|
||||||
icon = null,
|
icon = null,
|
||||||
enabled = rememberUpdatedState(enabled),
|
enabled = rememberUpdatedState(enabled)
|
||||||
onSelected = { selectedRole.value = it }
|
) { selectedRole.value = it }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Check,
|
painterResource(R.drawable.ic_check),
|
||||||
stringResource(R.string.invite_to_group_button),
|
stringResource(R.string.invite_to_group_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
@@ -193,7 +196,7 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun SkipInvitingButton(onClick: () -> Unit) {
|
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Check,
|
painterResource(R.drawable.ic_check),
|
||||||
stringResource(R.string.skip_inviting_button),
|
stringResource(R.string.skip_inviting_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
@@ -211,7 +214,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
if (selectedContactsCount >= 1) {
|
if (selectedContactsCount >= 1) {
|
||||||
Text(
|
Text(
|
||||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
@@ -219,14 +222,14 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.clear_contacts_selection_button),
|
stringResource(R.string.clear_contacts_selection_button),
|
||||||
color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_contacts_selected),
|
stringResource(R.string.no_contacts_selected),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -249,9 +252,6 @@ fun ContactList(
|
|||||||
checked = selectedContacts.contains(contact.apiId),
|
checked = selectedContacts.contains(contact.apiId),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
if (index < contacts.lastIndex) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,17 +266,17 @@ fun ContactCheckRow(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
) {
|
) {
|
||||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||||
val icon: ImageVector
|
val icon: Painter
|
||||||
val iconColor: Color
|
val iconColor: Color
|
||||||
if (prohibitedToInviteIncognito) {
|
if (prohibitedToInviteIncognito) {
|
||||||
icon = Icons.Filled.TheaterComedy
|
icon = painterResource(R.drawable.ic_theater_comedy_filled)
|
||||||
iconColor = HighOrLowlight
|
iconColor = MaterialTheme.colors.secondary
|
||||||
} else if (checked) {
|
} else if (checked) {
|
||||||
icon = Icons.Filled.CheckCircle
|
icon = painterResource(R.drawable.ic_check_circle_filled)
|
||||||
iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
|
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
} else {
|
} else {
|
||||||
icon = Icons.Outlined.Circle
|
icon = painterResource(R.drawable.ic_circle)
|
||||||
iconColor = HighOrLowlight
|
iconColor = MaterialTheme.colors.secondary
|
||||||
}
|
}
|
||||||
SectionItemView(
|
SectionItemView(
|
||||||
click = if (enabled) {
|
click = if (enabled) {
|
||||||
@@ -294,7 +294,7 @@ fun ContactCheckRow(
|
|||||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||||
Text(
|
Text(
|
||||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
|
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||||
)
|
)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Icon(
|
Icon(
|
||||||
@@ -318,6 +318,7 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
|
|||||||
fun PreviewAddGroupMembersLayout() {
|
fun PreviewAddGroupMembersLayout() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModelIncognito = false,
|
||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
creatingGroup = false,
|
creatingGroup = false,
|
||||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||||
|
|||||||
+100
-129
@@ -1,7 +1,8 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
@@ -11,12 +12,11 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -34,7 +34,7 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.usersettings.*
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdated: (String?) -> Unit, close: () -> Unit) {
|
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
@@ -82,6 +82,9 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
|
|||||||
editGroupProfile = {
|
editGroupProfile = {
|
||||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||||
},
|
},
|
||||||
|
addOrEditWelcomeMessage = {
|
||||||
|
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
|
||||||
|
},
|
||||||
openPreferences = {
|
openPreferences = {
|
||||||
ModalManager.shared.showCustomModal { close ->
|
ModalManager.shared.showCustomModal { close ->
|
||||||
GroupPreferencesView(
|
GroupPreferencesView(
|
||||||
@@ -95,9 +98,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, onGroupLinkUpdat
|
|||||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||||
manageGroupLink = {
|
manageGroupLink = {
|
||||||
withApi {
|
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, onGroupLinkUpdated) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
|
|||||||
val alertTextKey =
|
val alertTextKey =
|
||||||
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
||||||
else R.string.delete_group_for_self_cannot_undo_warning
|
else R.string.delete_group_for_self_cannot_undo_warning
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_group_question),
|
title = generalGetString(R.string.delete_group_question),
|
||||||
text = generalGetString(alertTextKey),
|
text = generalGetString(alertTextKey),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -121,12 +122,13 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.leave_group_question),
|
title = generalGetString(R.string.leave_group_question),
|
||||||
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||||
confirmText = generalGetString(R.string.leave_group_button),
|
confirmText = generalGetString(R.string.leave_group_button),
|
||||||
@@ -135,7 +137,8 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
|
|||||||
chatModel.controller.leaveGroup(groupInfo.groupId)
|
chatModel.controller.leaveGroup(groupInfo.groupId)
|
||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +152,7 @@ fun GroupChatInfoLayout(
|
|||||||
addMembers: () -> Unit,
|
addMembers: () -> Unit,
|
||||||
showMemberInfo: (GroupMember) -> Unit,
|
showMemberInfo: (GroupMember) -> Unit,
|
||||||
editGroupProfile: () -> Unit,
|
editGroupProfile: () -> Unit,
|
||||||
|
addOrEditWelcomeMessage: () -> Unit,
|
||||||
openPreferences: () -> Unit,
|
openPreferences: () -> Unit,
|
||||||
deleteGroup: () -> Unit,
|
deleteGroup: () -> Unit,
|
||||||
clearChat: () -> Unit,
|
clearChat: () -> Unit,
|
||||||
@@ -158,8 +162,7 @@ fun GroupChatInfoLayout(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState())
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -171,61 +174,50 @@ fun GroupChatInfoLayout(
|
|||||||
|
|
||||||
SectionView {
|
SectionView {
|
||||||
if (groupInfo.canEdit) {
|
if (groupInfo.canEdit) {
|
||||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
EditGroupProfileButton(editGroupProfile)
|
||||||
SectionDivider()
|
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
|
||||||
}
|
}
|
||||||
GroupPreferencesButton(openPreferences)
|
GroupPreferencesButton(openPreferences)
|
||||||
}
|
}
|
||||||
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||||
if (groupInfo.canAddMembers) {
|
if (groupInfo.canAddMembers) {
|
||||||
SectionItemView(manageGroupLink) {
|
if (groupLink == null) {
|
||||||
if (groupLink == null) {
|
CreateGroupLinkButton(manageGroupLink)
|
||||||
CreateGroupLinkButton()
|
} else {
|
||||||
} else {
|
GroupLinkButton(manageGroupLink)
|
||||||
GroupLinkButton()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||||
SectionItemView(onAddMembersClick) {
|
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
|
AddMembersButton(tint, onAddMembersClick)
|
||||||
AddMembersButton(tint)
|
|
||||||
}
|
|
||||||
SectionDivider()
|
|
||||||
}
|
}
|
||||||
SectionItemView(minHeight = 50.dp) {
|
SectionItemView(minHeight = 54.dp) {
|
||||||
MemberRow(groupInfo.membership, user = true)
|
MemberRow(groupInfo.membership, user = true)
|
||||||
}
|
}
|
||||||
if (members.isNotEmpty()) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
MembersList(members, showMemberInfo)
|
MembersList(members, showMemberInfo)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
SectionView {
|
SectionView {
|
||||||
ClearChatButton(clearChat)
|
ClearChatButton(clearChat)
|
||||||
if (groupInfo.canDelete) {
|
if (groupInfo.canDelete) {
|
||||||
SectionDivider()
|
DeleteGroupButton(deleteGroup)
|
||||||
SectionItemView(deleteGroup) { DeleteGroupButton() }
|
|
||||||
}
|
}
|
||||||
if (groupInfo.membership.memberCurrent) {
|
if (groupInfo.membership.memberCurrent) {
|
||||||
SectionDivider()
|
LeaveGroupButton(leaveGroup)
|
||||||
SectionItemView(leaveGroup) { LeaveGroupButton() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,38 +248,31 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.ToggleOn,
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
stringResource(R.string.group_preferences),
|
stringResource(R.string.group_preferences),
|
||||||
click = onClick
|
click = onClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier.fillMaxSize(),
|
painterResource(R.drawable.ic_add),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
stringResource(R.string.button_add_members),
|
||||||
) {
|
onClick,
|
||||||
Icon(
|
iconColor = tint,
|
||||||
Icons.Outlined.Add,
|
textColor = tint
|
||||||
stringResource(R.string.button_add_members),
|
)
|
||||||
tint = tint
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_add_members), color = tint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
members.forEachIndexed { index, member ->
|
members.forEachIndexed { index, member ->
|
||||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
Divider()
|
||||||
|
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
|
||||||
MemberRow(member)
|
MemberRow(member)
|
||||||
}
|
}
|
||||||
if (index < members.lastIndex) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,10 +285,12 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 46.dp, member.image)
|
ProfileImage(size = 46.dp, member.image)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||||
Column {
|
Column {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (member.verified) {
|
if (member.verified) {
|
||||||
@@ -318,7 +305,7 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
|||||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||||
Text(
|
Text(
|
||||||
statusDescr,
|
statusDescr,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -327,97 +314,81 @@ private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
|||||||
}
|
}
|
||||||
val role = member.memberRole
|
val role = member.memberRole
|
||||||
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
||||||
Text(role.text, color = HighOrLowlight)
|
Text(role.text, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MemberVerifiedShield() {
|
private fun MemberVerifiedShield() {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GroupLinkButton() {
|
private fun GroupLinkButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier
|
painterResource(R.drawable.ic_link),
|
||||||
.fillMaxSize(),
|
stringResource(R.string.group_link),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onClick,
|
||||||
) {
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.Link,
|
|
||||||
stringResource(R.string.group_link),
|
|
||||||
tint = HighOrLowlight
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.group_link))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CreateGroupLinkButton() {
|
private fun CreateGroupLinkButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier
|
painterResource(R.drawable.ic_add_link),
|
||||||
.fillMaxSize(),
|
stringResource(R.string.create_group_link),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onClick,
|
||||||
) {
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.AddLink,
|
|
||||||
stringResource(R.string.create_group_link),
|
|
||||||
tint = HighOrLowlight
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.create_group_link))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditGroupProfileButton() {
|
fun EditGroupProfileButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier
|
painterResource(R.drawable.ic_edit),
|
||||||
.fillMaxSize(),
|
stringResource(R.string.button_edit_group_profile),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onClick,
|
||||||
) {
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.Edit,
|
|
||||||
stringResource(R.string.button_edit_group_profile),
|
|
||||||
tint = HighOrLowlight
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_edit_group_profile))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LeaveGroupButton() {
|
private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) {
|
||||||
Row(
|
val text = if (welcomeMessage == null) {
|
||||||
Modifier.fillMaxSize(),
|
stringResource(R.string.button_add_welcome_message)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
} else {
|
||||||
) {
|
stringResource(R.string.button_welcome_message)
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Logout,
|
|
||||||
stringResource(R.string.button_leave_group),
|
|
||||||
tint = Color.Red
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_leave_group), color = Color.Red)
|
|
||||||
}
|
}
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_maps_ugc),
|
||||||
|
text,
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeleteGroupButton() {
|
private fun LeaveGroupButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier.fillMaxSize(),
|
painterResource(R.drawable.ic_logout),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
stringResource(R.string.button_leave_group),
|
||||||
) {
|
onClick,
|
||||||
Icon(
|
iconColor = Color.Red,
|
||||||
Icons.Outlined.Delete,
|
textColor = Color.Red
|
||||||
stringResource(R.string.button_delete_group),
|
)
|
||||||
tint = Color.Red
|
}
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
@Composable
|
||||||
Text(stringResource(R.string.button_delete_group), color = Color.Red)
|
private fun DeleteGroupButton(onClick: () -> Unit) {
|
||||||
}
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
stringResource(R.string.button_delete_group),
|
||||||
|
onClick,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@@ -433,7 +404,7 @@ fun PreviewGroupChatInfoLayout() {
|
|||||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
groupLink = null,
|
groupLink = null,
|
||||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.CircularProgressIndicator
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.GroupInfo
|
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.newchat.QRCode
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, onGroupLinkUpdated: (String?) -> Unit) {
|
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
|
||||||
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||||
|
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||||
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||||
val cxt = LocalContext.current
|
val cxt = LocalContext.current
|
||||||
fun createLink() {
|
fun createLink() {
|
||||||
creatingLink = true
|
creatingLink = true
|
||||||
withApi {
|
withApi {
|
||||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||||
onGroupLinkUpdated(groupLink)
|
if (link != null) {
|
||||||
|
groupLink = link.first
|
||||||
|
groupLinkMemberRole.value = link.second
|
||||||
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
|
}
|
||||||
creatingLink = false
|
creatingLink = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,11 +46,26 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
|||||||
}
|
}
|
||||||
GroupLinkLayout(
|
GroupLinkLayout(
|
||||||
groupLink = groupLink,
|
groupLink = groupLink,
|
||||||
|
groupInfo,
|
||||||
|
groupLinkMemberRole,
|
||||||
creatingLink,
|
creatingLink,
|
||||||
createLink = ::createLink,
|
createLink = ::createLink,
|
||||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||||
|
updateLink = {
|
||||||
|
val role = groupLinkMemberRole.value
|
||||||
|
if (role != null) {
|
||||||
|
withBGApi {
|
||||||
|
val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
|
||||||
|
if (link != null) {
|
||||||
|
groupLink = link.first
|
||||||
|
groupLinkMemberRole.value = link.second
|
||||||
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteLink = {
|
deleteLink = {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_link_question),
|
title = generalGetString(R.string.delete_link_question),
|
||||||
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -54,10 +74,11 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
|||||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||||
if (r) {
|
if (r) {
|
||||||
groupLink = null
|
groupLink = null
|
||||||
onGroupLinkUpdated(null)
|
onGroupLinkUpdated(null to null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -69,20 +90,22 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
|||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkLayout(
|
fun GroupLinkLayout(
|
||||||
groupLink: String?,
|
groupLink: String?,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
||||||
creatingLink: Boolean,
|
creatingLink: Boolean,
|
||||||
createLink: () -> Unit,
|
createLink: () -> Unit,
|
||||||
share: () -> Unit,
|
share: () -> Unit,
|
||||||
|
updateLink: () -> Unit,
|
||||||
deleteLink: () -> Unit
|
deleteLink: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
Modifier
|
||||||
horizontalAlignment = Alignment.Start,
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.group_link), false)
|
AppBarTitle(stringResource(R.string.group_link))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||||
Modifier.padding(bottom = 12.dp),
|
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp),
|
||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
@@ -91,28 +114,55 @@ fun GroupLinkLayout(
|
|||||||
verticalArrangement = Arrangement.SpaceEvenly
|
verticalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
if (groupLink == null) {
|
if (groupLink == null) {
|
||||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, disabled = creatingLink, click = createLink)
|
SimpleButton(stringResource(R.string.button_create_group_link), icon = painterResource(R.drawable.ic_add_link), disabled = creatingLink, click = createLink)
|
||||||
} else {
|
} else {
|
||||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
||||||
|
var initialLaunch by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(groupLinkMemberRole.value) {
|
||||||
|
if (!initialLaunch) {
|
||||||
|
updateLink()
|
||||||
|
}
|
||||||
|
initialLaunch = false
|
||||||
|
}
|
||||||
|
QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 10.dp)
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
||||||
) {
|
) {
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.share_link),
|
stringResource(R.string.share_link),
|
||||||
icon = Icons.Outlined.Share,
|
icon = painterResource(R.drawable.ic_share),
|
||||||
click = share
|
click = share
|
||||||
)
|
)
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.delete_link),
|
stringResource(R.string.delete_link),
|
||||||
icon = Icons.Outlined.Delete,
|
icon = painterResource(R.drawable.ic_delete),
|
||||||
color = Color.Red,
|
color = Color.Red,
|
||||||
click = deleteLink
|
click = deleteLink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole?>, enabled: Boolean = true) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.initial_member_role),
|
||||||
|
values,
|
||||||
|
selectedRole,
|
||||||
|
icon = null,
|
||||||
|
enabled = rememberUpdatedState(enabled)
|
||||||
|
) { selectedRole.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +176,7 @@ fun ProgressIndicator() {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-49
@@ -1,29 +1,25 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
import SectionItemView
|
import SectionDividerSpaced
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.*
|
import chat.simplex.app.views.chat.*
|
||||||
@@ -54,20 +50,15 @@ fun GroupMemberInfoView(
|
|||||||
developerTools,
|
developerTools,
|
||||||
connectionCode,
|
connectionCode,
|
||||||
getContactChat = { chatModel.getContactChat(it) },
|
getContactChat = { chatModel.getContactChat(it) },
|
||||||
knownDirectChat = {
|
openDirectChat = {
|
||||||
withApi {
|
|
||||||
chatModel.chatItems.clear()
|
|
||||||
chatModel.chatItems.addAll(it.chatItems)
|
|
||||||
chatModel.chatId.value = it.chatInfo.id
|
|
||||||
closeAll()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
newDirectChat = {
|
|
||||||
withApi {
|
withApi {
|
||||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||||
if (c != null) {
|
if (c != null) {
|
||||||
chatModel.addChat(c)
|
if (chatModel.getContactChat(it) == null) {
|
||||||
|
chatModel.addChat(c)
|
||||||
|
}
|
||||||
chatModel.chatItems.clear()
|
chatModel.chatItems.clear()
|
||||||
|
chatModel.chatItems.addAll(c.chatItems)
|
||||||
chatModel.chatId.value = c.id
|
chatModel.chatId.value = c.id
|
||||||
closeAll()
|
closeAll()
|
||||||
}
|
}
|
||||||
@@ -125,7 +116,7 @@ fun GroupMemberInfoView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.button_remove_member),
|
title = generalGetString(R.string.button_remove_member),
|
||||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||||
@@ -137,7 +128,8 @@ fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: Cha
|
|||||||
}
|
}
|
||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +142,7 @@ fun GroupMemberInfoLayout(
|
|||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
connectionCode: String?,
|
connectionCode: String?,
|
||||||
getContactChat: (Long) -> Chat?,
|
getContactChat: (Long) -> Chat?,
|
||||||
knownDirectChat: (Chat) -> Unit,
|
openDirectChat: (Long) -> Unit,
|
||||||
newDirectChat: (Long) -> Unit,
|
|
||||||
removeMember: () -> Unit,
|
removeMember: () -> Unit,
|
||||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||||
switchMemberAddress: () -> Unit,
|
switchMemberAddress: () -> Unit,
|
||||||
@@ -161,7 +152,6 @@ fun GroupMemberInfoLayout(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -176,16 +166,8 @@ fun GroupMemberInfoLayout(
|
|||||||
if (contactId != null) {
|
if (contactId != null) {
|
||||||
SectionView {
|
SectionView {
|
||||||
val chat = getContactChat(contactId)
|
val chat = getContactChat(contactId)
|
||||||
if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
|
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||||
OpenChatButton(onClick = { knownDirectChat(chat) })
|
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||||
if (connectionCode != null) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
} else if (groupInfo.fullGroupPreferences.directMessages.on) {
|
|
||||||
OpenChatButton(onClick = { newDirectChat(contactId) })
|
|
||||||
if (connectionCode != null) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (connectionCode != null) {
|
if (connectionCode != null) {
|
||||||
VerifyCodeButton(member.verified, verifyClicked)
|
VerifyCodeButton(member.verified, verifyClicked)
|
||||||
@@ -197,36 +179,30 @@ fun GroupMemberInfoLayout(
|
|||||||
|
|
||||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||||
SectionDivider()
|
|
||||||
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
||||||
if (roles != null) {
|
if (roles != null) {
|
||||||
SectionItemView {
|
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||||
RoleSelectionRow(roles, newRole, onRoleSelected)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||||
}
|
}
|
||||||
val conn = member.activeConn
|
val conn = member.activeConn
|
||||||
if (conn != null) {
|
if (conn != null) {
|
||||||
SectionDivider()
|
|
||||||
val connLevelDesc =
|
val connLevelDesc =
|
||||||
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||||
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||||
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
if (connStats != null) {
|
if (connStats != null) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
SwitchAddressButton(switchMemberAddress)
|
SwitchAddressButton(switchMemberAddress)
|
||||||
SectionDivider()
|
|
||||||
val rcvServers = connStats.rcvServers
|
val rcvServers = connStats.rcvServers
|
||||||
val sndServers = connStats.sndServers
|
val sndServers = connStats.sndServers
|
||||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
}
|
}
|
||||||
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
@@ -234,24 +210,23 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (member.canBeRemoved(groupInfo)) {
|
if (member.canBeRemoved(groupInfo)) {
|
||||||
|
SectionDividerSpaced(maxBottomPadding = false)
|
||||||
SectionView {
|
SectionView {
|
||||||
RemoveMemberButton(removeMember)
|
RemoveMemberButton(removeMember)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +239,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (member.verified) {
|
if (member.verified) {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
@@ -287,7 +262,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun RemoveMemberButton(onClick: () -> Unit) {
|
fun RemoveMemberButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.button_remove_member),
|
stringResource(R.string.button_remove_member),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -298,7 +273,7 @@ fun RemoveMemberButton(onClick: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun OpenChatButton(onClick: () -> Unit) {
|
fun OpenChatButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Message,
|
painterResource(R.drawable.ic_chat),
|
||||||
stringResource(R.string.button_send_direct_message),
|
stringResource(R.string.button_send_direct_message),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
@@ -364,8 +339,7 @@ fun PreviewGroupMemberInfoLayout() {
|
|||||||
developerTools = false,
|
developerTools = false,
|
||||||
connectionCode = "123",
|
connectionCode = "123",
|
||||||
getContactChat = { Chat.sampleData },
|
getContactChat = { Chat.sampleData },
|
||||||
knownDirectChat = {},
|
openDirectChat = {},
|
||||||
newDirectChat = {},
|
|
||||||
removeMember = {},
|
removeMember = {},
|
||||||
onRoleSelected = {},
|
onRoleSelected = {},
|
||||||
switchMemberAddress = {},
|
switchMemberAddress = {},
|
||||||
|
|||||||
+18
-25
@@ -1,9 +1,9 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -12,7 +12,6 @@ import androidx.compose.material.MaterialTheme
|
|||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
@@ -45,7 +44,6 @@ fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
|||||||
if (preferences == currentPreferences) close()
|
if (preferences == currentPreferences) close()
|
||||||
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
},
|
},
|
||||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
|
||||||
) {
|
) {
|
||||||
GroupPreferencesLayout(
|
GroupPreferencesLayout(
|
||||||
preferences,
|
preferences,
|
||||||
@@ -73,7 +71,6 @@ private fun GroupPreferencesLayout(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.group_preferences))
|
AppBarTitle(stringResource(R.string.group_preferences))
|
||||||
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||||
@@ -87,29 +84,30 @@ private fun GroupPreferencesLayout(
|
|||||||
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
|
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||||
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
|
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
|
||||||
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||||
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
|
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
|
||||||
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||||
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
||||||
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||||
}
|
}
|
||||||
if (groupInfo.canEdit) {
|
if (groupInfo.canEdit) {
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
ResetSaveButtons(
|
ResetSaveButtons(
|
||||||
reset = reset,
|
reset = reset,
|
||||||
save = savePrefs,
|
save = savePrefs,
|
||||||
disabled = preferences == currentPreferences
|
disabled = preferences == currentPreferences
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,22 +122,19 @@ private fun FeatureSection(
|
|||||||
) {
|
) {
|
||||||
SectionView {
|
SectionView {
|
||||||
val on = enableFeature.value == GroupFeatureEnabled.ON
|
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||||
val icon = if (on) feature.iconFilled else feature.icon
|
val icon = if (on) feature.iconFilled() else feature.icon
|
||||||
val iconTint = if (on) SimplexGreen else HighOrLowlight
|
val iconTint = if (on) SimplexGreen else MaterialTheme.colors.secondary
|
||||||
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
|
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
|
||||||
if (groupInfo.canEdit) {
|
if (groupInfo.canEdit) {
|
||||||
SectionItemView {
|
PreferenceToggleWithIcon(
|
||||||
PreferenceToggleWithIcon(
|
feature.text,
|
||||||
feature.text,
|
icon,
|
||||||
icon,
|
iconTint,
|
||||||
iconTint,
|
enableFeature.value == GroupFeatureEnabled.ON,
|
||||||
enableFeature.value == GroupFeatureEnabled.ON,
|
) { checked ->
|
||||||
) { checked ->
|
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
|
||||||
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (timedOn) {
|
if (timedOn) {
|
||||||
SectionDivider()
|
|
||||||
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
||||||
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
}
|
}
|
||||||
@@ -151,7 +146,6 @@ private fun FeatureSection(
|
|||||||
iconTint = iconTint,
|
iconTint = iconTint,
|
||||||
)
|
)
|
||||||
if (timedOn) {
|
if (timedOn) {
|
||||||
SectionDivider()
|
|
||||||
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,11 +157,10 @@ private fun FeatureSection(
|
|||||||
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
SectionView {
|
SectionView {
|
||||||
SectionItemView(reset, disabled = disabled) {
|
SectionItemView(reset, disabled = disabled) {
|
||||||
Text(stringResource(R.string.reset_verb), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
SectionItemView(save, disabled = disabled) {
|
SectionItemView(save, disabled = disabled) {
|
||||||
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-55
@@ -1,7 +1,7 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -23,6 +24,7 @@ import chat.simplex.app.ui.theme.*
|
|||||||
import chat.simplex.app.views.ProfileNameField
|
import chat.simplex.app.views.ProfileNameField
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.isValidDisplayName
|
import chat.simplex.app.views.isValidDisplayName
|
||||||
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
import chat.simplex.app.views.usersettings.*
|
import chat.simplex.app.views.usersettings.*
|
||||||
import com.google.accompanist.insets.ProvideWindowInsets
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
@@ -53,14 +55,32 @@ fun GroupProfileLayout(
|
|||||||
saveProfile: (GroupProfile) -> Unit,
|
saveProfile: (GroupProfile) -> Unit,
|
||||||
) {
|
) {
|
||||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
||||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
||||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val dataUnchanged =
|
||||||
|
displayName.value == groupProfile.displayName &&
|
||||||
|
fullName.value == groupProfile.fullName &&
|
||||||
|
groupProfile.image == profileImage.value
|
||||||
|
val closeWithAlert = {
|
||||||
|
if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
showUnsavedChangesAlert({
|
||||||
|
saveProfile(
|
||||||
|
groupProfile.copy(
|
||||||
|
displayName = displayName.value,
|
||||||
|
fullName = fullName.value,
|
||||||
|
image = profileImage.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||||
@@ -76,23 +96,16 @@ fun GroupProfileLayout(
|
|||||||
sheetState = bottomSheetModalState,
|
sheetState = bottomSheetModalState,
|
||||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||||
) {
|
) {
|
||||||
ModalView(close = close) {
|
ModalView(close = closeWithAlert) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = DEFAULT_PADDING),
|
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
stringResource(R.string.group_profile_is_stored_on_members_devices),
|
|
||||||
Modifier.padding(bottom = 24.dp),
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
lineHeight = 22.sp
|
|
||||||
)
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth()
|
||||||
horizontalAlignment = Alignment.Start
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
) {
|
) {
|
||||||
|
ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -101,7 +114,7 @@ fun GroupProfileLayout(
|
|||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.TopEnd) {
|
Box(contentAlignment = Alignment.TopEnd) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
ProfileImage(192.dp, profileImage.value)
|
ProfileImage(108.dp, profileImage.value, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f))
|
||||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||||
}
|
}
|
||||||
if (profileImage.value != null) {
|
if (profileImage.value != null) {
|
||||||
@@ -109,51 +122,54 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
stringResource(R.string.group_display_name_field),
|
Text(
|
||||||
Modifier.padding(bottom = 3.dp)
|
stringResource(R.string.group_display_name_field),
|
||||||
)
|
fontSize = 16.sp
|
||||||
ProfileNameField(displayName, focusRequester)
|
)
|
||||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
Text(
|
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||||
errorText,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
color = MaterialTheme.colors.error
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.group_full_name_field),
|
|
||||||
Modifier.padding(bottom = 5.dp)
|
|
||||||
)
|
|
||||||
ProfileNameField(fullName)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Row {
|
|
||||||
TextButton(stringResource(R.string.cancel_verb)) {
|
|
||||||
close.invoke()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
|
||||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
|
||||||
if (enabled) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.save_group_profile),
|
stringResource(R.string.no_spaces),
|
||||||
modifier = Modifier.clickable {
|
fontSize = 16.sp,
|
||||||
saveProfile(groupProfile.copy(
|
color = Color.Red
|
||||||
displayName = displayName.value,
|
|
||||||
fullName = fullName.value,
|
|
||||||
image = profileImage.value
|
|
||||||
))
|
|
||||||
},
|
|
||||||
color = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.save_group_profile),
|
|
||||||
color = HighOrLowlight
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.group_full_name_field),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
|
)
|
||||||
|
ProfileNameField(fullName)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
|
if (enabled) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.save_group_profile),
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
saveProfile(
|
||||||
|
groupProfile.copy(
|
||||||
|
displayName = displayName.value,
|
||||||
|
fullName = fullName.value,
|
||||||
|
image = profileImage.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.save_group_profile),
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionBottomSpacer()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(300)
|
delay(300)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
@@ -164,6 +180,16 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||||
|
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||||
|
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||||
|
|
||||||
|
fun save(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||||
|
if (welcome?.length == 0) {
|
||||||
|
welcome = null
|
||||||
|
}
|
||||||
|
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||||
|
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||||
|
if (res != null) {
|
||||||
|
groupInfo = res
|
||||||
|
m.updateGroup(res)
|
||||||
|
welcomeText.value = welcome ?: ""
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||||
|
else showUnsavedChangesAlert({ save(close) }, close)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
GroupWelcomeLayout(
|
||||||
|
welcomeText,
|
||||||
|
groupInfo,
|
||||||
|
save = ::save
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupWelcomeLayout(
|
||||||
|
welcomeText: MutableState<String>,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
save: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.group_welcome_title))
|
||||||
|
val welcomeText = remember { welcomeText }
|
||||||
|
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||||
|
SectionSpacer()
|
||||||
|
SaveButton(
|
||||||
|
save = save,
|
||||||
|
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||||
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_welcome_message_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -24,28 +21,28 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
@Composable fun ConnectingCallIcon() = Icon(painterResource(R.drawable.ic_settings_phone), stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||||
when (status) {
|
when (status) {
|
||||||
CICallStatus.Pending -> if (sent) {
|
CICallStatus.Pending -> if (sent) {
|
||||||
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_pending_sent))
|
||||||
} else {
|
} else {
|
||||||
AcceptCallButton(cInfo, acceptCall)
|
AcceptCallButton(cInfo, acceptCall)
|
||||||
}
|
}
|
||||||
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
CICallStatus.Missed -> Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
CICallStatus.Rejected -> Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
CICallStatus.Progress -> Icon(painterResource(R.drawable.ic_phone_in_talk_filled), stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||||
CICallStatus.Ended -> Row {
|
CICallStatus.Ended -> Row {
|
||||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
||||||
Text(durationText(duration), color = HighOrLowlight)
|
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
CICallStatus.Error -> {}
|
CICallStatus.Error -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
cItem.timestampText,
|
cItem.timestampText,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
)
|
)
|
||||||
@@ -55,9 +52,9 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
@Composable
|
@Composable
|
||||||
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||||
if (cInfo is ChatInfo.Direct) {
|
if (cInfo is ChatInfo.Direct) {
|
||||||
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
|
SimpleButton(stringResource(R.string.answer_call), painterResource(R.drawable.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
||||||
} else {
|
} else {
|
||||||
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_ring_volume), stringResource(R.string.answer_call), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
// if case let .direct(contact) = chatInfo {
|
// if case let .direct(contact) = chatInfo {
|
||||||
// Button {
|
// Button {
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
@@ -16,14 +16,14 @@ fun CIChatFeatureView(
|
|||||||
chatItem: ChatItem,
|
chatItem: ChatItem,
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
iconColor: Color,
|
iconColor: Color,
|
||||||
icon: ImageVector? = null
|
icon: Painter? = null
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Icon(icon ?: feature.iconFilled, feature.text, Modifier.size(18.dp), tint = iconColor)
|
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
|
||||||
Text(
|
Text(
|
||||||
chatEventText(chatItem),
|
chatEventText(chatItem),
|
||||||
Modifier,
|
Modifier,
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIEventView(ci: ChatItem) {
|
fun CIEventView(ci: ChatItem) {
|
||||||
@@ -22,28 +21,25 @@ fun CIEventView(ci: ChatItem) {
|
|||||||
fun chatEventTextView(text: AnnotatedString) {
|
fun chatEventTextView(text: AnnotatedString) {
|
||||||
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
Surface {
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
Row(
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
val memberDisplayName = ci.memberDisplayName
|
||||||
) {
|
if (memberDisplayName != null) {
|
||||||
val memberDisplayName = ci.memberDisplayName
|
chatEventTextView(
|
||||||
if (memberDisplayName != null) {
|
buildAnnotatedString {
|
||||||
chatEventTextView(
|
withStyle(chatEventStyle) { append(memberDisplayName) }
|
||||||
buildAnnotatedString {
|
append(" ")
|
||||||
withStyle(chatEventStyle) { append(memberDisplayName) }
|
}.plus(chatEventText(ci))
|
||||||
append(" ")
|
)
|
||||||
}.plus(chatEventText(ci))
|
} else {
|
||||||
)
|
chatEventTextView(chatEventText(ci))
|
||||||
} else {
|
|
||||||
chatEventTextView(chatEventText(ci))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
|
||||||
|
|
||||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
|
|||||||
+2
-3
@@ -11,7 +11,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,7 +26,7 @@ fun CIFeaturePreferenceView(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = HighOrLowlight)
|
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary)
|
||||||
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||||
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||||
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||||
@@ -48,7 +47,7 @@ fun CIFeaturePreferenceView(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(chatItem.content.text + " " + chatItem.timestampText,
|
Text(chatItem.content.text + " " + chatItem.timestampText,
|
||||||
fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)
|
fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,18 @@ package chat.simplex.app.views.chat.item
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.*
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -37,14 +36,14 @@ fun CIFileView(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun fileIcon(
|
fun fileIcon(
|
||||||
innerIcon: ImageVector? = null,
|
innerIcon: Painter? = null,
|
||||||
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
tint = color
|
tint = color
|
||||||
@@ -64,7 +63,7 @@ fun CIFileView(
|
|||||||
|
|
||||||
fun fileSizeValid(): Boolean {
|
fun fileSizeValid(): Boolean {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
return file.fileSize <= MAX_FILE_SIZE
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -72,22 +71,30 @@ fun CIFileView(
|
|||||||
fun fileAction() {
|
fun fileAction() {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.RcvInvitation -> {
|
is CIFileStatus.RcvInvitation -> {
|
||||||
if (fileSizeValid()) {
|
if (fileSizeValid()) {
|
||||||
receiveFile(file.fileId)
|
receiveFile(file.fileId)
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CIFileStatus.RcvAccepted ->
|
is CIFileStatus.RcvAccepted ->
|
||||||
AlertManager.shared.showAlertMsg(
|
when (file.fileProtocol) {
|
||||||
generalGetString(R.string.waiting_for_file),
|
FileProtocol.XFTP ->
|
||||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
AlertManager.shared.showAlertMsg(
|
||||||
)
|
generalGetString(R.string.waiting_for_file),
|
||||||
CIFileStatus.RcvComplete -> {
|
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_file),
|
||||||
|
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvComplete -> {
|
||||||
val filePath = getLoadedFilePath(context, file)
|
val filePath = getLoadedFilePath(context, file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
saveFileLauncher.launch(file.fileName)
|
saveFileLauncher.launch(file.fileName)
|
||||||
@@ -105,10 +112,24 @@ fun CIFileView(
|
|||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
Modifier.size(32.dp),
|
Modifier.size(32.dp),
|
||||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||||
strokeWidth = 4.dp
|
strokeWidth = 3.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun progressCircle(progress: Long, total: Long) {
|
||||||
|
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||||
|
val strokeColor = if (isInDarkTheme()) FileDark else FileLight
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = Color.Transparent,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun fileIndicator() {
|
fun fileIndicator() {
|
||||||
Box(
|
Box(
|
||||||
@@ -120,19 +141,34 @@ fun CIFileView(
|
|||||||
) {
|
) {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.SndStored -> fileIcon()
|
is CIFileStatus.SndStored ->
|
||||||
CIFileStatus.SndTransfer -> progressIndicator()
|
when (file.fileProtocol) {
|
||||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
FileProtocol.SMP -> fileIcon()
|
||||||
CIFileStatus.RcvInvitation ->
|
}
|
||||||
|
is CIFileStatus.SndTransfer ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
|
FileProtocol.SMP -> progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(R.drawable.ic_check_filled))
|
||||||
|
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.RcvInvitation ->
|
||||||
if (fileSizeValid())
|
if (fileSizeValid())
|
||||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||||
else
|
else
|
||||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_priority_high), color = WarningOrange)
|
||||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(R.drawable.ic_more_horiz))
|
||||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
is CIFileStatus.RcvTransfer ->
|
||||||
CIFileStatus.RcvComplete -> fileIcon()
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
|
} else {
|
||||||
|
progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvComplete -> fileIcon()
|
||||||
|
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileIcon()
|
fileIcon()
|
||||||
@@ -151,16 +187,14 @@ fun CIFileView(
|
|||||||
else
|
else
|
||||||
" "
|
" "
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
Column(
|
Column {
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
file.fileName,
|
file.fileName,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
formatBytes(file.fileSize) + metaReserve,
|
formatBytes(file.fileSize) + metaReserve,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
@@ -191,7 +225,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
ChatItem.getFileMsgContentSample(),
|
ChatItem.getFileMsgContentSample(),
|
||||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10)),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||||
|
|||||||
+5
-7
@@ -1,13 +1,10 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -46,7 +43,7 @@ fun CIGroupInvitationView(
|
|||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.padding(end = 2.dp)
|
.padding(end = 2.dp)
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
|
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = R.drawable.ic_supervised_user_circle_filled, color = iconColor)
|
||||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||||
Column(
|
Column(
|
||||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||||
@@ -71,12 +68,14 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
modifier = if (action) Modifier.clickable(onClick = {
|
modifier = if (action) Modifier.clickable(onClick = {
|
||||||
joinGroup(groupInvitation.groupId)
|
joinGroup(groupInvitation.groupId)
|
||||||
}) else Modifier,
|
}) else Modifier,
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
color = if (sent) sentColor else receivedColor,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -89,7 +88,6 @@ fun CIGroupInvitationView(
|
|||||||
Modifier
|
Modifier
|
||||||
.defaultMinSize(minWidth = 220.dp)
|
.defaultMinSize(minWidth = 220.dp)
|
||||||
.padding(bottom = 4.dp),
|
.padding(bottom = 4.dp),
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
groupInfoView()
|
groupInfoView()
|
||||||
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
||||||
@@ -108,7 +106,7 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
ci.timestampText,
|
ci.timestampText,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package chat.simplex.app.views.chat.item
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build.VERSION.SDK_INT
|
import android.os.Build.VERSION.SDK_INT
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.CircularProgressIndicator
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -21,14 +18,14 @@ import androidx.compose.ui.graphics.painter.Painter
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.layoutId
|
import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.CIFileStatus
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
@@ -45,6 +42,25 @@ fun CIImageView(
|
|||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long) -> Unit
|
receiveFile: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
|
@Composable
|
||||||
|
fun progressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(stringId),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun loadingIndicator() {
|
fun loadingIndicator() {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
@@ -55,39 +71,20 @@ fun CIImageView(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.SndTransfer ->
|
is CIFileStatus.SndStored ->
|
||||||
CircularProgressIndicator(
|
when (file.fileProtocol) {
|
||||||
Modifier.size(16.dp),
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
color = Color.White,
|
FileProtocol.SMP -> {}
|
||||||
strokeWidth = 2.dp
|
}
|
||||||
)
|
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||||
CIFileStatus.SndComplete ->
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_image_snd_complete)
|
||||||
Icon(
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
Icons.Filled.Check,
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
stringResource(R.string.icon_descr_image_snd_complete),
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_asked_to_receive)
|
||||||
Modifier.fillMaxSize(),
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_image)
|
||||||
tint = Color.White
|
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||||
)
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
CIFileStatus.RcvAccepted ->
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MoreHoriz,
|
|
||||||
stringResource(R.string.icon_descr_waiting_for_image),
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
CIFileStatus.RcvTransfer ->
|
|
||||||
CircularProgressIndicator(
|
|
||||||
Modifier.size(16.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
CIFileStatus.RcvInvitation ->
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.ArrowDownward,
|
|
||||||
stringResource(R.string.icon_descr_asked_to_receive),
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +133,7 @@ fun CIImageView(
|
|||||||
|
|
||||||
fun fileSizeValid(): Boolean {
|
fun fileSizeValid(): Boolean {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
return file.fileSize <= MAX_FILE_SIZE
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -179,15 +176,23 @@ fun CIImageView(
|
|||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CIFileStatus.RcvAccepted ->
|
CIFileStatus.RcvAccepted ->
|
||||||
AlertManager.shared.showAlertMsg(
|
when (file.fileProtocol) {
|
||||||
generalGetString(R.string.waiting_for_image),
|
FileProtocol.XFTP ->
|
||||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
AlertManager.shared.showAlertMsg(
|
||||||
)
|
generalGetString(R.string.waiting_for_image),
|
||||||
CIFileStatus.RcvTransfer -> {} // ?
|
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_image),
|
||||||
|
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
CIFileStatus.RcvComplete -> {} // ?
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
CIFileStatus.RcvCancelled -> {} // TODO
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|||||||
+2
-3
@@ -5,12 +5,11 @@ import SectionView
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Share
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -35,7 +34,7 @@ fun InvalidJSONView(json: String) {
|
|||||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
SectionView {
|
SectionView {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
SettingsActionItem(Icons.Outlined.Share, generalGetString(R.string.share_verb), click = {
|
SettingsActionItem(painterResource(R.drawable.ic_share), generalGetString(R.string.share_verb), click = {
|
||||||
shareText(context, json)
|
shareText(context, json)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,23 @@ package chat.simplex.app.views.chat.item
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.material.icons.filled.Circle
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.Edit
|
import chat.simplex.app.R
|
||||||
import androidx.compose.material.icons.outlined.Timer
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = HighOrLowlight) {
|
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) {
|
||||||
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (chatItem.isDeletedContent) {
|
if (chatItem.isDeletedContent) {
|
||||||
Text(
|
Text(
|
||||||
@@ -38,11 +37,11 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Hi
|
|||||||
// changing this function requires updating reserveSpaceForMeta
|
// changing this function requires updating reserveSpaceForMeta
|
||||||
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||||
if (meta.itemEdited) {
|
if (meta.itemEdited) {
|
||||||
StatusIconText(Icons.Outlined.Edit, color)
|
StatusIconText(painterResource(R.drawable.ic_edit), color)
|
||||||
Spacer(Modifier.width(3.dp))
|
Spacer(Modifier.width(3.dp))
|
||||||
}
|
}
|
||||||
if (meta.disappearing) {
|
if (meta.disappearing) {
|
||||||
StatusIconText(Icons.Outlined.Timer, color)
|
StatusIconText(painterResource(R.drawable.ic_timer), color)
|
||||||
val ttl = meta.itemTimed?.ttl
|
val ttl = meta.itemTimed?.ttl
|
||||||
if (ttl != chatTTL) {
|
if (ttl != chatTTL) {
|
||||||
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
||||||
@@ -52,13 +51,13 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
|||||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||||
if (statusIcon != null) {
|
if (statusIcon != null) {
|
||||||
val (icon, statusColor) = statusIcon
|
val (icon, statusColor) = statusIcon
|
||||||
StatusIconText(icon, statusColor)
|
StatusIconText(painterResource(icon), statusColor)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
} else if (!meta.disappearing) {
|
} else if (!meta.disappearing) {
|
||||||
StatusIconText(Icons.Filled.Circle, Color.Transparent)
|
StatusIconText(painterResource(R.drawable.ic_circle_filled), Color.Transparent)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
}
|
||||||
Text(meta.timestampText, color = color, fontSize = 13.sp)
|
Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the conditions in this function should match CIMetaText
|
// the conditions in this function should match CIMetaText
|
||||||
@@ -73,14 +72,14 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
|
|||||||
res += TimedMessagesPreference.shortTtlText(ttl)
|
res += TimedMessagesPreference.shortTtlText(ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (meta.statusIcon(HighOrLowlight) != null || !meta.disappearing) {
|
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
|
||||||
res += iconSpace
|
res += iconSpace
|
||||||
}
|
}
|
||||||
return res + meta.timestampText
|
return res + meta.timestampText
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusIconText(icon: ImageVector, color: Color) {
|
private fun StatusIconText(icon: Painter, color: Color) {
|
||||||
Icon(icon, null, Modifier.height(12.dp), tint = color)
|
Icon(icon, null, Modifier.height(12.dp), tint = color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) {
|
||||||
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.decryption_error),
|
||||||
|
text = when (msgDecryptError) {
|
||||||
|
MsgDecryptError.RatchetHeader -> String.format(generalGetString(R.string.alert_text_decryption_error_header), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
MsgDecryptError.TooManySkipped -> String.format(generalGetString(R.string.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.*
|
||||||
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIVideoView(
|
||||||
|
image: String,
|
||||||
|
duration: Int,
|
||||||
|
file: CIFile?,
|
||||||
|
imageProvider: () -> ImageGalleryProvider,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
receiveFile: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||||
|
contentAlignment = Alignment.TopEnd
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||||
|
val preview = remember(image) { base64ToBitmap(image) }
|
||||||
|
if (file != null && filePath != null) {
|
||||||
|
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||||
|
val view = LocalView.current
|
||||||
|
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||||
|
hideKeyboard(view)
|
||||||
|
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||||
|
ImageFullScreenView(imageProvider, close)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Box {
|
||||||
|
ImageView(preview, showMenu, onClick = {
|
||||||
|
if (file != null) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
CIFileStatus.RcvInvitation ->
|
||||||
|
receiveFileIfValidSize(file, receiveFile)
|
||||||
|
CIFileStatus.RcvAccepted ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_video),
|
||||||
|
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_video),
|
||||||
|
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (file != null) {
|
||||||
|
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||||
|
}
|
||||||
|
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
||||||
|
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingIndicator(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||||
|
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||||
|
val progress = remember(uri.path) { player.progress }
|
||||||
|
val duration = remember(uri.path) { player.duration }
|
||||||
|
val preview by remember { player.preview }
|
||||||
|
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||||
|
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||||
|
val play = {
|
||||||
|
player.enableSound(true)
|
||||||
|
player.play(true)
|
||||||
|
}
|
||||||
|
val stop = {
|
||||||
|
player.enableSound(false)
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
val windowWidth = LocalWindowWidth()
|
||||||
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
StyledPlayerView(ctx).apply {
|
||||||
|
useController = false
|
||||||
|
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||||
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Modifier
|
||||||
|
.width(width)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (showPreview.value) {
|
||||||
|
ImageView(preview, showMenu, onClick)
|
||||||
|
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||||
|
}
|
||||||
|
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
Modifier.align(Alignment.Center),
|
||||||
|
color = Color.Black.copy(alpha = 0.25f),
|
||||||
|
shape = RoundedCornerShape(percent = 50)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (error) WarningOrange else Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, duration: MutableState<Long>, progress: MutableState<Long>/*, soundEnabled: MutableState<Boolean>*/) {
|
||||||
|
if (duration.value > 0L || progress.value > 0) {
|
||||||
|
Row {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(DEFAULT_PADDING_HALF)
|
||||||
|
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||||
|
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
val time = if (progress.value > 0) progress.value else duration.value
|
||||||
|
val timeStr = durationText((time / 1000).toInt())
|
||||||
|
val width = if (timeStr.length <= 5) 44 else 50
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
/*if (!soundEnabled.value) {
|
||||||
|
Icon(painterResource(R.drawable.ic_volume_off_filled), null,
|
||||||
|
Modifier.padding(start = 5.dp).size(10.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
if (!playing.value) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(top = DEFAULT_PADDING_HALF)
|
||||||
|
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||||
|
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
formatBytes(file.fileSize),
|
||||||
|
Modifier.padding(horizontal = 4.dp),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
|
val windowWidth = LocalWindowWidth()
|
||||||
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
|
Image(
|
||||||
|
preview.asImageBitmap(),
|
||||||
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(width)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = onClick
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocalWindowWidth(): Dp {
|
||||||
|
val view = LocalView.current
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
return remember {
|
||||||
|
val rect = Rect()
|
||||||
|
view.getWindowVisibleDisplayFrame(rect)
|
||||||
|
(rect.width() / density).dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun progressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(stringId),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun progressCircle(progress: Long, total: Long) {
|
||||||
|
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() }
|
||||||
|
val strokeColor = Color.White
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = Color.Transparent,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun loadingIndicator(file: CIFile?) {
|
||||||
|
if (file != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(20.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
is CIFileStatus.SndStored ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
|
FileProtocol.SMP -> {}
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndTransfer ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
|
FileProtocol.SMP -> progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_video_snd_complete)
|
||||||
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_video_asked_to_receive)
|
||||||
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_video)
|
||||||
|
is CIFileStatus.RcvTransfer ->
|
||||||
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
|
} else {
|
||||||
|
progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileSizeValid(file: CIFile?): Boolean {
|
||||||
|
if (file != null) {
|
||||||
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||||
|
if (fileSizeValid(file)) {
|
||||||
|
receiveFile(file.fileId)
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.large_file),
|
||||||
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
||||||
|
val approximatePadding = 100.dp
|
||||||
|
return minOf(1000.dp, windowWidth - approximatePadding)
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CornerSize
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,7 +17,9 @@ import androidx.compose.ui.graphics.*
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
@@ -137,7 +137,7 @@ private fun DurationText(text: State<String>, padding: PaddingValues) {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.widthIn(min = minWidth),
|
.widthIn(min = minWidth),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
@@ -156,9 +156,11 @@ private fun PlayPauseButton(
|
|||||||
pause: () -> Unit,
|
pause: () -> Unit,
|
||||||
longClick: () -> Unit
|
longClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
color = if (sent) sentColor else receivedColor,
|
||||||
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -171,10 +173,10 @@ private fun PlayPauseButton(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
Modifier.size(36.dp),
|
Modifier.size(36.dp),
|
||||||
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
|
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,7 @@ private fun VoiceMsgIndicator(
|
|||||||
if (hasText) {
|
if (hasText) {
|
||||||
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
Modifier.size(36.dp),
|
Modifier.size(36.dp),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
@@ -210,9 +212,9 @@ private fun VoiceMsgIndicator(
|
|||||||
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (file?.fileStatus == CIFileStatus.RcvInvitation
|
if (file?.fileStatus is CIFileStatus.RcvInvitation
|
||||||
|| file?.fileStatus == CIFileStatus.RcvTransfer
|
|| file?.fileStatus is CIFileStatus.RcvTransfer
|
||||||
|| file?.fileStatus == CIFileStatus.RcvAccepted
|
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -228,7 +230,7 @@ private fun VoiceMsgIndicator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||||
val brush = Brush.linearGradient(
|
val brush = Brush.linearGradient(
|
||||||
0f to Color.Transparent,
|
0f to Color.Transparent,
|
||||||
0f to color,
|
0f to color,
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.chat.ComposeContextItem
|
import chat.simplex.app.views.chat.ComposeContextItem
|
||||||
import chat.simplex.app.views.chat.ComposeState
|
import chat.simplex.app.views.chat.ComposeState
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||||
@@ -40,6 +41,7 @@ fun ChatItemView(
|
|||||||
linkMode: SimplexLinkMode,
|
linkMode: SimplexLinkMode,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
scrollToItem: (Long) -> Unit,
|
scrollToItem: (Long) -> Unit,
|
||||||
@@ -91,15 +93,19 @@ fun ChatItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun moderateMessageQuestionText(): String {
|
||||||
|
return if (fullDeleteAllowed) {
|
||||||
|
generalGetString(R.string.moderate_message_will_be_deleted_warning)
|
||||||
|
} else {
|
||||||
|
generalGetString(R.string.moderate_message_will_be_marked_warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MsgContentItemDropdownMenu() {
|
fun MsgContentItemDropdownMenu() {
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu.value,
|
|
||||||
onDismissRequest = { showMenu.value = false },
|
|
||||||
Modifier.width(220.dp)
|
|
||||||
) {
|
|
||||||
if (cItem.meta.itemDeleted == null && !live) {
|
if (cItem.meta.itemDeleted == null && !live) {
|
||||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
|
||||||
if (composeState.value.editing) {
|
if (composeState.value.editing) {
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +114,7 @@ fun ChatItemView(
|
|||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||||
when {
|
when {
|
||||||
filePath != null -> shareFile(context, cItem.text, filePath)
|
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||||
@@ -116,18 +122,24 @@ fun ChatItemView(
|
|||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
|
||||||
copyText(context, cItem.content.text)
|
copyText(context, cItem.content.text)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||||
val filePath = getLoadedFilePath(context, cItem.file)
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
|
||||||
when (cItem.content.msgContent) {
|
when (cItem.content.msgContent) {
|
||||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
is MsgContent.MCImage -> {
|
||||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||||
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
saveImage(context, cItem.file)
|
||||||
|
} else {
|
||||||
|
writePermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -135,7 +147,7 @@ fun ChatItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
@@ -143,34 +155,39 @@ fun ChatItemView(
|
|||||||
if (cItem.meta.itemDeleted != null && revealed.value) {
|
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.hide_verb),
|
stringResource(R.string.hide_verb),
|
||||||
Icons.Outlined.VisibilityOff,
|
painterResource(R.drawable.ic_visibility_off),
|
||||||
onClick = {
|
onClick = {
|
||||||
revealed.value = false
|
revealed.value = false
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
|
||||||
|
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||||
|
}
|
||||||
if (!(live && cItem.meta.isLive)) {
|
if (!(live && cItem.meta.isLive)) {
|
||||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
}
|
}
|
||||||
|
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||||
|
if (groupInfo != null) {
|
||||||
|
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkedDeletedItemDropdownMenu() {
|
fun MarkedDeletedItemDropdownMenu() {
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu.value,
|
if (!cItem.isDeletedContent) {
|
||||||
onDismissRequest = { showMenu.value = false },
|
ItemAction(
|
||||||
Modifier.width(220.dp)
|
stringResource(R.string.reveal_verb),
|
||||||
) {
|
painterResource(R.drawable.ic_visibility),
|
||||||
ItemAction(
|
onClick = {
|
||||||
stringResource(R.string.reveal_verb),
|
revealed.value = true
|
||||||
Icons.Outlined.Visibility,
|
showMenu.value = false
|
||||||
onClick = {
|
}
|
||||||
revealed.value = true
|
)
|
||||||
showMenu.value = false
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,11 +217,7 @@ fun ChatItemView(
|
|||||||
|
|
||||||
@Composable fun DeletedItem() {
|
@Composable fun DeletedItem() {
|
||||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu.value,
|
|
||||||
onDismissRequest = { showMenu.value = false },
|
|
||||||
Modifier.width(220.dp)
|
|
||||||
) {
|
|
||||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +233,8 @@ fun ChatItemView(
|
|||||||
is CIContent.RcvDeleted -> DeletedItem()
|
is CIContent.RcvDeleted -> DeletedItem()
|
||||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||||
@@ -233,19 +247,37 @@ fun ChatItemView(
|
|||||||
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||||
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||||
}
|
}
|
||||||
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, HighOrLowlight, icon = c.feature.icon,)
|
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
|
||||||
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||||
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||||
is CIContent.SndModerated -> DeletedItem()
|
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
is CIContent.RcvModerated -> DeletedItem()
|
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CancelFileItemAction(
|
||||||
|
fileId: Long,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
|
cancelAction: CancelAction
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(cancelAction.uiActionId),
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteItemAction(
|
fun DeleteItemAction(
|
||||||
cItem: ChatItem,
|
cItem: ChatItem,
|
||||||
@@ -255,7 +287,7 @@ fun DeleteItemAction(
|
|||||||
) {
|
) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_verb),
|
stringResource(R.string.delete_verb),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
@@ -265,22 +297,75 @@ fun DeleteItemAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
fun ModerateItemAction(
|
||||||
DropdownMenuItem(onClick) {
|
cItem: ChatItem,
|
||||||
Row {
|
questionText: String,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.moderate_verb),
|
||||||
|
painterResource(R.drawable.ic_flag),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1F)
|
.weight(1F)
|
||||||
.padding(end = 15.dp),
|
.padding(end = 15.dp),
|
||||||
color = color
|
color = finalColor
|
||||||
)
|
)
|
||||||
Icon(icon, text, tint = color)
|
Icon(icon, text, tint = finalColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F)
|
||||||
|
.padding(end = 15.dp),
|
||||||
|
color = finalColor
|
||||||
|
)
|
||||||
|
Icon(icon, text, tint = finalColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(cancelAction.alert.titleId),
|
||||||
|
text = generalGetString(cancelAction.alert.messageId),
|
||||||
|
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
cancelFile(fileId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogButtons(
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
title = generalGetString(R.string.delete_message__question),
|
title = generalGetString(R.string.delete_message__question),
|
||||||
@@ -290,24 +375,36 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
}) { Text(stringResource(R.string.for_me_only)) }
|
}) { Text(stringResource(R.string.for_me_only), color = MaterialTheme.colors.error) }
|
||||||
if (chatItem.meta.editable) {
|
if (chatItem.meta.editable) {
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
}) { Text(stringResource(R.string.for_everybody)) }
|
}) { Text(stringResource(R.string.for_everybody), color = MaterialTheme.colors.error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_member_message__question),
|
||||||
|
text = questionText,
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(R.string.message_delivery_error_title),
|
title = generalGetString(R.string.message_delivery_error_title),
|
||||||
@@ -329,6 +426,7 @@ fun PreviewChatItemView() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
scrollToItem = {},
|
scrollToItem = {},
|
||||||
@@ -349,6 +447,7 @@ fun PreviewChatItemViewDeletedContent() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
scrollToItem = {},
|
scrollToItem = {},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -13,15 +14,16 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
val sent = ci.chatDir.sent
|
val sent = ci.chatDir.sent
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
color = if (sent) sentColor else receivedColor,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
@@ -30,7 +32,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
|
|||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -14,12 +11,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.*
|
import androidx.compose.ui.layout.*
|
||||||
import androidx.compose.ui.platform.UriHandler
|
import androidx.compose.ui.platform.UriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.*
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.util.fastMap
|
import androidx.compose.ui.util.fastMap
|
||||||
@@ -30,11 +29,6 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
val SentColorLight = Color(0x1E45B8FF)
|
|
||||||
val ReceivedColorLight = Color(0x20B1B0B5)
|
|
||||||
val SentQuoteColorLight = Color(0x2545B8FF)
|
|
||||||
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FramedItemView(
|
fun FramedItemView(
|
||||||
chatInfo: ChatInfo,
|
chatInfo: ChatInfo,
|
||||||
@@ -55,6 +49,9 @@ fun FramedItemView(
|
|||||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuotedMsgView(qi: CIQuote) {
|
fun ciQuotedMsgView(qi: CIQuote) {
|
||||||
Box(
|
Box(
|
||||||
@@ -70,15 +67,14 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: ImageVector? = null) {
|
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp)
|
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||||
.padding(end = 12.dp)
|
|
||||||
.padding(top = 6.dp)
|
|
||||||
.padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
@@ -91,20 +87,24 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = HighOrLowlight)) {
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) {
|
||||||
append(caption)
|
append(caption)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuoteView(qi: CIQuote) {
|
fun ciQuoteView(qi: CIQuote) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onLongClick = { showMenu.value = true },
|
onLongClick = { showMenu.value = true },
|
||||||
@@ -124,12 +124,24 @@ fun FramedItemView(
|
|||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVideo -> {
|
||||||
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
ciQuotedMsgView(qi)
|
||||||
|
}
|
||||||
|
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||||
|
Image(
|
||||||
|
imageBitmap,
|
||||||
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
|
)
|
||||||
|
}
|
||||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.Mic,
|
if (qi.content is MsgContent.MCFile) painterResource(R.drawable.ic_draft_filled) else painterResource(R.drawable.ic_mic_filled),
|
||||||
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
@@ -150,23 +162,30 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
|
||||||
|
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||||
|
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Box(Modifier
|
Box(Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
transparentBackground -> Color.Transparent
|
transparentBackground -> Color.Transparent
|
||||||
sent -> SentColorLight
|
sent -> sentColor
|
||||||
else -> ReceivedColorLight
|
else -> receivedColor
|
||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
var metaColor = HighOrLowlight
|
var metaColor = MaterialTheme.colors.secondary
|
||||||
Box(contentAlignment = Alignment.BottomEnd) {
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||||
if (ci.meta.itemDeleted != null) {
|
if (ci.meta.itemDeleted != null) {
|
||||||
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete)
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
|
FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(R.drawable.ic_flag))
|
||||||
|
} else {
|
||||||
|
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, painterResource(R.drawable.ic_delete))
|
||||||
|
}
|
||||||
} else if (ci.meta.isLive) {
|
} else if (ci.meta.isLive) {
|
||||||
FramedItemHeader(stringResource(R.string.live), false)
|
FramedItemHeader(stringResource(R.string.live), false)
|
||||||
}
|
}
|
||||||
@@ -193,6 +212,14 @@ fun FramedItemView(
|
|||||||
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVideo -> {
|
||||||
|
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||||
|
if (mc.text == "" && !ci.meta.isLive) {
|
||||||
|
metaColor = Color.White
|
||||||
|
} else {
|
||||||
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
is MsgContent.MCVoice -> {
|
is MsgContent.MCVoice -> {
|
||||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||||
if (mc.text != "") {
|
if (mc.text != "") {
|
||||||
|
|||||||
+119
-54
@@ -3,21 +3,28 @@ package chat.simplex.app.views.chat.item
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.chat.ProviderMedia
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
@@ -26,13 +33,16 @@ import coil.decode.ImageDecoderDecoder
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import com.google.accompanist.pager.*
|
import com.google.accompanist.pager.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
interface ImageGalleryProvider {
|
interface ImageGalleryProvider {
|
||||||
val initialIndex: Int
|
val initialIndex: Int
|
||||||
val totalImagesSize: MutableState<Int>
|
val totalMediaSize: MutableState<Int>
|
||||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
fun getMedia(index: Int): ProviderMedia?
|
||||||
fun currentPageChanged(index: Int)
|
fun currentPageChanged(index: Int)
|
||||||
fun scrollToStart()
|
fun scrollToStart()
|
||||||
fun onDismiss(index: Int)
|
fun onDismiss(index: Int)
|
||||||
@@ -48,13 +58,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
if (provider.getMedia(provider.initialIndex - 1) == null) {
|
||||||
provider.scrollToStart()
|
provider.scrollToStart()
|
||||||
pagerState.scrollToPage(0)
|
pagerState.scrollToPage(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||||
|
DisposableEffectOnGone(
|
||||||
|
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||||
|
)
|
||||||
|
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -74,13 +88,13 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
if (settledCurrentPage != provider.initialIndex)
|
if (settledCurrentPage != provider.initialIndex)
|
||||||
provider.currentPageChanged(index)
|
provider.currentPageChanged(index)
|
||||||
}
|
}
|
||||||
val image = provider.getImage(index)
|
val media = provider.getMedia(index)
|
||||||
if (image == null) {
|
if (media == null) {
|
||||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||||
SideEffect {
|
SideEffect {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
when (settledCurrentPage) {
|
when (settledCurrentPage) {
|
||||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1
|
||||||
index + 1 -> {
|
index + 1 -> {
|
||||||
provider.scrollToStart()
|
provider.scrollToStart()
|
||||||
pagerState.scrollToPage(0)
|
pagerState.scrollToPage(0)
|
||||||
@@ -89,7 +103,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
|
||||||
var scale by remember { mutableStateOf(1f) }
|
var scale by remember { mutableStateOf(1f) }
|
||||||
var translationX by remember { mutableStateOf(0f) }
|
var translationX by remember { mutableStateOf(0f) }
|
||||||
var translationY by remember { mutableStateOf(0f) }
|
var translationY by remember { mutableStateOf(0f) }
|
||||||
@@ -100,54 +113,106 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
translationX = 0f
|
translationX = 0f
|
||||||
translationY = 0f
|
translationY = 0f
|
||||||
}
|
}
|
||||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
val modifier = Modifier
|
||||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
.onGloballyPositioned {
|
||||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
viewWidth = it.size.width
|
||||||
.components {
|
|
||||||
if (Build.VERSION.SDK_INT >= 28) {
|
|
||||||
add(ImageDecoderDecoder.Factory())
|
|
||||||
} else {
|
|
||||||
add(GifDecoder.Factory())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.build()
|
.graphicsLayer(
|
||||||
Image(
|
scaleX = scale,
|
||||||
rememberAsyncImagePainter(
|
scaleY = scale,
|
||||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
translationX = translationX,
|
||||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
translationY = translationY,
|
||||||
imageLoader = imageLoader
|
)
|
||||||
),
|
.pointerInput(Unit) {
|
||||||
contentDescription = stringResource(R.string.image_descr),
|
detectTransformGestures(
|
||||||
contentScale = ContentScale.Fit,
|
{ allowTranslate },
|
||||||
modifier = Modifier
|
onGesture = { _, pan, gestureZoom, _ ->
|
||||||
.onGloballyPositioned {
|
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||||
viewWidth = it.size.width
|
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||||
}
|
if (scale > 1 && allowTranslate) {
|
||||||
.graphicsLayer(
|
translationX += pan.x * scale
|
||||||
scaleX = scale,
|
translationY += pan.y * scale
|
||||||
scaleY = scale,
|
} else if (allowTranslate) {
|
||||||
translationX = translationX,
|
translationX = 0f
|
||||||
translationY = translationY,
|
translationY = 0f
|
||||||
)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTransformGestures(
|
|
||||||
{ allowTranslate },
|
|
||||||
onGesture = { _, pan, gestureZoom, _ ->
|
|
||||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
|
||||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
|
||||||
if (scale > 1 && allowTranslate) {
|
|
||||||
translationX += pan.x * scale
|
|
||||||
translationY += pan.y * scale
|
|
||||||
} else if (allowTranslate) {
|
|
||||||
translationX = 0f
|
|
||||||
translationY = 0f
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fillMaxSize()
|
||||||
|
if (media is ProviderMedia.Image) {
|
||||||
|
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||||
|
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||||
|
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||||
|
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.fillMaxSize(),
|
.build()
|
||||||
)
|
Image(
|
||||||
|
rememberAsyncImagePainter(
|
||||||
|
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||||
|
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||||
|
imageLoader = imageLoader
|
||||||
|
),
|
||||||
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else if (media is ProviderMedia.Video) {
|
||||||
|
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||||
|
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { playersToRelease.add(media.uri) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||||
|
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||||
|
val play = {
|
||||||
|
player.play(true)
|
||||||
|
}
|
||||||
|
val stop = {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
player.enableSound(true)
|
||||||
|
snapshotFlow { isCurrentPage.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { if (it) play() else stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
StyledPlayerView(ctx).apply {
|
||||||
|
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||||
|
} else {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
}
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
setShowNextButton(false)
|
||||||
|
setShowSubtitleButton(false)
|
||||||
|
setShowVrButton(false)
|
||||||
|
controllerAutoShow = false
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||||
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-8
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -17,21 +18,45 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.model.MsgErrorType
|
||||||
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.AlertManager
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
|
when (msgError) {
|
||||||
|
is MsgErrorType.MsgSkipped ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||||
|
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||||
|
)
|
||||||
|
is MsgErrorType.MsgBadHash ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_msg_bad_hash),
|
||||||
|
text = generalGetString(R.string.alert_text_msg_bad_hash) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
|
)
|
||||||
|
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_msg_bad_id),
|
||||||
|
text = generalGetString(R.string.alert_text_msg_bad_id) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
Modifier.clickable(onClick = {
|
Modifier.clickable(onClick = onClick),
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
|
||||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = ReceivedColorLight,
|
color = receivedColor,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
@@ -59,6 +84,7 @@ fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boo
|
|||||||
fun IntegrityErrorItemViewPreview() {
|
fun IntegrityErrorItemViewPreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
IntegrityErrorItemView(
|
IntegrityErrorItemView(
|
||||||
|
MsgErrorType.MsgBadHash(),
|
||||||
ChatItem.getDeletedContentSampleData(),
|
ChatItem.getDeletedContentSampleData(),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-13
@@ -1,48 +1,63 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.CIDeleted
|
import chat.simplex.app.model.CIDeleted
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight,
|
color = if (ci.chatDir.sent) sentColor else receivedColor,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Box(Modifier.weight(1f, false)) {
|
||||||
buildAnnotatedString {
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||||
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) }
|
} else {
|
||||||
},
|
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
}
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
}
|
||||||
)
|
|
||||||
CIMetaView(ci, timedMessagesTTL)
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MarkedDeletedText(text: String) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import androidx.compose.ui.unit.*
|
|||||||
import androidx.core.text.BidiFormatter
|
import androidx.core.text.BidiFormatter
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.views.helpers.detectGesture
|
import chat.simplex.app.views.helpers.detectGesture
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ private val typingIndicators: List<AnnotatedString> = listOf(
|
|||||||
|
|
||||||
|
|
||||||
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||||
pushStyle(SpanStyle(color = HighOrLowlight, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||||
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ fun ClickableText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, shouldConsumeEvent = { pos ->
|
}, shouldConsumeEvent = { pos ->
|
||||||
var consume = false
|
var consume = false
|
||||||
layoutResult.value?.let { layoutResult ->
|
layoutResult.value?.let { layoutResult ->
|
||||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.PersonAdd
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -19,6 +18,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||||
|
import chat.simplex.app.views.helpers.openUriCatching
|
||||||
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -36,14 +35,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
Text(
|
Text(
|
||||||
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||||
modifier = Modifier.clickable(onClick = {
|
modifier = Modifier.clickable(onClick = {
|
||||||
uriHandler.openUri(simplexTeamUri)
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
}),
|
}),
|
||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(top = 24.dp),
|
Modifier.padding(top = 24.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -57,7 +55,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.chat_help_tap_button))
|
Text(stringResource(R.string.chat_help_tap_button))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.PersonAdd,
|
painterResource(R.drawable.ic_person_add),
|
||||||
stringResource(R.string.add_contact),
|
stringResource(R.string.add_contact),
|
||||||
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||||
)
|
)
|
||||||
@@ -69,7 +67,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(top = 24.dp),
|
Modifier.padding(top = 24.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
||||||
@@ -80,7 +77,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(vertical = 24.dp),
|
Modifier.padding(vertical = 24.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||||
|
|||||||
+25
-45
@@ -4,14 +4,12 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -183,7 +181,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
|
|||||||
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.mark_read),
|
stringResource(R.string.mark_read),
|
||||||
Icons.Outlined.Check,
|
painterResource(R.drawable.ic_check),
|
||||||
onClick = {
|
onClick = {
|
||||||
markChatRead(chat, chatModel)
|
markChatRead(chat, chatModel)
|
||||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
@@ -194,33 +192,21 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
DropdownMenuItem({
|
ItemAction(
|
||||||
markChatUnread(chat, chatModel)
|
stringResource(R.string.mark_unread),
|
||||||
showMenu.value = false
|
painterResource(R.drawable.ic_mark_chat_unread),
|
||||||
}) {
|
onClick = {
|
||||||
Row {
|
markChatUnread(chat, chatModel)
|
||||||
Text(
|
showMenu.value = false
|
||||||
stringResource(R.string.mark_unread),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1F)
|
|
||||||
.padding(end = 15.dp),
|
|
||||||
color = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MarkChatUnread,
|
|
||||||
stringResource(R.string.mark_unread),
|
|
||||||
tint = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||||
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
if (ntfsEnabled) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||||
onClick = {
|
onClick = {
|
||||||
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -232,7 +218,7 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled:
|
|||||||
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.clear_chat_menu_action),
|
stringResource(R.string.clear_chat_menu_action),
|
||||||
Icons.Outlined.Restore,
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
onClick = {
|
onClick = {
|
||||||
clearChatDialog(chat.chatInfo, chatModel)
|
clearChatDialog(chat.chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -245,7 +231,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
|
|||||||
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_contact_menu_action),
|
stringResource(R.string.delete_contact_menu_action),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteContactDialog(chat.chatInfo, chatModel)
|
deleteContactDialog(chat.chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -258,7 +244,7 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
|
|||||||
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_group_menu_action),
|
stringResource(R.string.delete_group_menu_action),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -272,7 +258,7 @@ fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, show
|
|||||||
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
||||||
if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
|
if (chat.chatInfo.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_login),
|
||||||
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||||
onClick = {
|
onClick = {
|
||||||
joinGroup()
|
joinGroup()
|
||||||
@@ -285,7 +271,7 @@ fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, show
|
|||||||
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.leave_group_button),
|
stringResource(R.string.leave_group_button),
|
||||||
Icons.Outlined.Logout,
|
painterResource(R.drawable.ic_logout),
|
||||||
onClick = {
|
onClick = {
|
||||||
leaveGroupDialog(groupInfo, chatModel)
|
leaveGroupDialog(groupInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -298,7 +284,7 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
|
|||||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||||
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
|
if (chatModel.incognito.value) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_check),
|
||||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||||
onClick = {
|
onClick = {
|
||||||
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
||||||
@@ -307,7 +293,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
|||||||
)
|
)
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.reject_contact_button),
|
stringResource(R.string.reject_contact_button),
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
onClick = {
|
onClick = {
|
||||||
rejectContactRequest(chatInfo, chatModel)
|
rejectContactRequest(chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -320,7 +306,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
|||||||
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.set_contact_name),
|
stringResource(R.string.set_contact_name),
|
||||||
Icons.Outlined.Edit,
|
painterResource(R.drawable.ic_edit),
|
||||||
onClick = {
|
onClick = {
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||||
@@ -330,7 +316,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
|||||||
)
|
)
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_verb),
|
stringResource(R.string.delete_verb),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -342,7 +328,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
|||||||
@Composable
|
@Composable
|
||||||
private fun InvalidDataView() {
|
private fun InvalidDataView() {
|
||||||
Row {
|
Row {
|
||||||
ProfileImage(72.dp, null, Icons.Filled.AccountCircle, HighOrLowlight)
|
ProfileImage(72.dp, null, R.drawable.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = 8.dp)
|
||||||
@@ -447,7 +433,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
@@ -480,7 +466,8 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
|
|||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +485,7 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
destructive = true,
|
||||||
dismissText = generalGetString(R.string.cancel_verb),
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -589,15 +577,7 @@ fun ChatListNavLinkLayout(
|
|||||||
chatLinkPreview()
|
chatLinkPreview()
|
||||||
}
|
}
|
||||||
if (dropdownMenuItems != null) {
|
if (dropdownMenuItems != null) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu.value,
|
|
||||||
onDismissRequest = { showMenu.value = false },
|
|
||||||
Modifier.width(220.dp)
|
|
||||||
) {
|
|
||||||
dropdownMenuItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
|
|||||||
@@ -7,20 +7,19 @@ import androidx.compose.foundation.lazy.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
@@ -36,7 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit, stopped: Boolean) {
|
||||||
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
val showNewChatSheet = {
|
val showNewChatSheet = {
|
||||||
@@ -66,10 +65,10 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
val scaffoldState = rememberScaffoldState()
|
val scaffoldState = rememberScaffoldState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
Scaffold(
|
Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
|
||||||
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
|
|
||||||
scaffoldState = scaffoldState,
|
scaffoldState = scaffoldState,
|
||||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||||
|
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (searchInList.isEmpty()) {
|
if (searchInList.isEmpty()) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@@ -78,16 +77,17 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp),
|
||||||
elevation = FloatingActionButtonDefaults.elevation(
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
defaultElevation = 0.dp,
|
defaultElevation = 0.dp,
|
||||||
pressedElevation = 0.dp,
|
pressedElevation = 0.dp,
|
||||||
hoveredElevation = 0.dp,
|
hoveredElevation = 0.dp,
|
||||||
focusedElevation = 0.dp,
|
focusedElevation = 0.dp,
|
||||||
),
|
),
|
||||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
|
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
) {
|
) {
|
||||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group))
|
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(R.drawable.ic_edit_filled) else painterResource(R.drawable.ic_close), stringResource(R.string.add_contact_or_create_group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,6 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ChatList(chatModel, search = searchInList)
|
ChatList(chatModel, search = searchInList)
|
||||||
@@ -105,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||||
OnboardingButtons(showNewChatSheet)
|
OnboardingButtons(showNewChatSheet)
|
||||||
}
|
}
|
||||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +131,11 @@ private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
|||||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||||
uriHandler.openUri(simplexTeamUri)
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||||
val color = MaterialTheme.colors.primary
|
val color = MaterialTheme.colors.primaryVariant
|
||||||
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
||||||
val trianglePath = Path().apply {
|
val trianglePath = Path().apply {
|
||||||
moveTo(0.dp.toPx(), 0f)
|
moveTo(0.dp.toPx(), 0f)
|
||||||
@@ -159,7 +158,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
|||||||
onClick,
|
onClick,
|
||||||
shape = RoundedCornerShape(21.dp),
|
shape = RoundedCornerShape(21.dp),
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
backgroundColor = MaterialTheme.colors.primary
|
backgroundColor = MaterialTheme.colors.primaryVariant
|
||||||
),
|
),
|
||||||
elevation = null,
|
elevation = null,
|
||||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
||||||
@@ -180,7 +179,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +192,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Report,
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(R.string.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
@@ -208,9 +207,9 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
} else if (chatModel.users.isEmpty()) {
|
} else if (chatModel.users.isEmpty()) {
|
||||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||||
} else {
|
} else {
|
||||||
val users by remember { derivedStateOf { chatModel.users.toList() } }
|
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||||
val allRead = users
|
val allRead = users
|
||||||
.filter { !it.user.activeUser }
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
.all { u -> u.unreadCount == 0 }
|
.all { u -> u.unreadCount == 0 }
|
||||||
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||||
if (users.size == 1) {
|
if (users.size == 1) {
|
||||||
@@ -230,7 +229,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
)
|
)
|
||||||
if (chatModel.incognito.value) {
|
if (chatModel.incognito.value) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy,
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
stringResource(R.string.incognito),
|
stringResource(R.string.incognito),
|
||||||
tint = Indigo,
|
tint = Indigo,
|
||||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
@@ -247,7 +246,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||||
IconButton(onClick = onButtonClicked) {
|
IconButton(onClick = onButtonClicked) {
|
||||||
Box {
|
Box {
|
||||||
ProfileImage(
|
ProfileImage(
|
||||||
@@ -282,7 +281,7 @@ private fun ProgressIndicator() {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.text.InlineTextContent
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -46,10 +43,10 @@ fun ChatPreviewView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun groupInactiveIcon() {
|
fun groupInactiveIcon() {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Cancel,
|
painterResource(R.drawable.ic_cancel_filled),
|
||||||
stringResource(R.string.icon_descr_group_inactive),
|
stringResource(R.string.icon_descr_group_inactive),
|
||||||
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||||
tint = HighOrLowlight
|
tint = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,15 +76,15 @@ fun ChatPreviewView(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VerifiedIcon() {
|
fun VerifiedIcon() {
|
||||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
||||||
fun attachment(): Pair<ImageVector, String?>? =
|
fun attachment(): Pair<Int, String?>? =
|
||||||
when (draft.preview) {
|
when (draft.preview) {
|
||||||
is ComposePreview.FilePreview -> Icons.Filled.InsertDriveFile to draft.preview.fileName
|
is ComposePreview.FilePreview -> R.drawable.ic_draft_filled to draft.preview.fileName
|
||||||
is ComposePreview.ImagePreview -> Icons.Outlined.Image to null
|
is ComposePreview.MediaPreview -> R.drawable.ic_image to null
|
||||||
is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000)
|
is ComposePreview.VoicePreview -> R.drawable.ic_play_arrow_filled to durationText(draft.preview.durationMs / 1000)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,12 +105,12 @@ fun ChatPreviewView(
|
|||||||
"editIcon" to InlineTextContent(
|
"editIcon" to InlineTextContent(
|
||||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Outlined.EditNote, null, tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.primary)
|
||||||
},
|
},
|
||||||
"attachmentIcon" to InlineTextContent(
|
"attachmentIcon" to InlineTextContent(
|
||||||
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
) {
|
) {
|
||||||
Icon(attachment?.first ?: Icons.Outlined.EditNote, null, tint = HighOrLowlight)
|
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return text to inlineContent
|
return text to inlineContent
|
||||||
@@ -127,12 +124,12 @@ fun ChatPreviewView(
|
|||||||
if (cInfo.contact.verified) {
|
if (cInfo.contact.verified) {
|
||||||
VerifiedIcon()
|
VerifiedIcon()
|
||||||
}
|
}
|
||||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
when (cInfo.groupInfo.membership.memberStatus) {
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||||
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
|
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary)
|
||||||
else -> chatPreviewTitleText()
|
else -> chatPreviewTitleText()
|
||||||
}
|
}
|
||||||
else -> chatPreviewTitleText()
|
else -> chatPreviewTitleText()
|
||||||
@@ -173,12 +170,12 @@ fun ChatPreviewView(
|
|||||||
when (cInfo) {
|
when (cInfo) {
|
||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct ->
|
||||||
if (!cInfo.ready) {
|
if (!cInfo.ready) {
|
||||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
Text(stringResource(R.string.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
when (cInfo.groupInfo.membership.memberStatus) {
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
|
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -211,7 +208,7 @@ fun ChatPreviewView(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
@@ -224,10 +221,10 @@ fun ChatPreviewView(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (n > 0) unreadCountStr(n) else "",
|
if (n > 0) unreadCountStr(n) else "",
|
||||||
color = MaterialTheme.colors.onPrimary,
|
color = Color.White,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
.background(if (stopped || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
.badgeLayout()
|
.badgeLayout()
|
||||||
.padding(horizontal = 3.dp)
|
.padding(horizontal = 3.dp)
|
||||||
.padding(vertical = 1.dp)
|
.padding(vertical = 1.dp)
|
||||||
@@ -239,9 +236,9 @@ fun ChatPreviewView(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.NotificationsOff,
|
painterResource(R.drawable.ic_notifications_off_filled),
|
||||||
contentDescription = generalGetString(R.string.notifications),
|
contentDescription = generalGetString(R.string.notifications),
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 3.dp)
|
.padding(horizontal = 3.dp)
|
||||||
.padding(vertical = 1.dp)
|
.padding(vertical = 1.dp)
|
||||||
@@ -281,9 +278,9 @@ fun ChatStatusImage(s: NetworkStatus?) {
|
|||||||
val descr = s?.statusString
|
val descr = s?.statusString
|
||||||
if (s is NetworkStatus.Error) {
|
if (s is NetworkStatus.Error) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.ErrorOutline,
|
painterResource(R.drawable.ic_error),
|
||||||
contentDescription = descr,
|
contentDescription = descr,
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(19.dp)
|
.size(19.dp)
|
||||||
)
|
)
|
||||||
@@ -292,7 +289,7 @@ fun ChatStatusImage(s: NetworkStatus?) {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(15.dp),
|
.size(15.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 1.5.dp
|
strokeWidth = 1.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -3,16 +3,16 @@ package chat.simplex.app.views.chatlist
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
@@ -21,7 +21,7 @@ import chat.simplex.app.views.helpers.ProfileImage
|
|||||||
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||||
Row {
|
Row {
|
||||||
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
||||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
|
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) R.drawable.ic_add_link else R.drawable.ic_link)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -34,7 +34,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.h3,
|
style = MaterialTheme.typography.h3,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
@@ -42,11 +42,10 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
val ts = getTimestampText(contactConnection.updatedAt)
|
val ts = getTimestampText(contactConnection.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxHeight(),
|
Modifier.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-2
@@ -39,11 +39,10 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con
|
|||||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxHeight(),
|
Modifier.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -21,21 +19,23 @@ import androidx.compose.ui.text.intl.Locale
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.Indigo
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||||
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
|
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(it)) {
|
Box(Modifier.padding(it)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ShareList(chatModel, search = searchInList)
|
ShareList(chatModel, search = searchInList)
|
||||||
@@ -45,27 +45,45 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
|
||||||
|
chatModel.sharedContent.value = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyList() {
|
private fun EmptyList() {
|
||||||
Box {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
Text(stringResource(R.string.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||||
if (showSearch) {
|
if (showSearch) {
|
||||||
BackHandler(onBack = hideSearchOnBack)
|
BackHandler(onBack = hideSearchOnBack)
|
||||||
}
|
}
|
||||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||||
|
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||||
|
val navButton: @Composable RowScope.() -> Unit = {
|
||||||
|
when {
|
||||||
|
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
||||||
|
users.size > 1 -> {
|
||||||
|
val allRead = users
|
||||||
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
|
.all { u -> u.unreadCount == 0 }
|
||||||
|
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||||
|
userPickerState.value = AnimatedViewState.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +96,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Report,
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(R.string.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
@@ -87,13 +105,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
}
|
}
|
||||||
|
|
||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
|
navigationButton = navButton,
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
when (chatModel.sharedContent.value) {
|
when (chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> stringResource(R.string.share_message)
|
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||||
is SharedContent.Images -> stringResource(R.string.share_image)
|
is SharedContent.Media -> stringResource(R.string.share_image)
|
||||||
is SharedContent.File -> stringResource(R.string.share_file)
|
is SharedContent.File -> stringResource(R.string.share_file)
|
||||||
else -> stringResource(R.string.share_message)
|
else -> stringResource(R.string.share_message)
|
||||||
},
|
},
|
||||||
@@ -102,7 +120,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
)
|
)
|
||||||
if (chatModel.incognito.value) {
|
if (chatModel.incognito.value) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy,
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
stringResource(R.string.incognito),
|
stringResource(R.string.incognito),
|
||||||
tint = Indigo,
|
tint = Indigo,
|
||||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
package chat.simplex.app.views.chatlist
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
import SectionItemViewSpaceBetween
|
import SectionItemView
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Done
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
@@ -33,10 +32,24 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, switchingUsers: MutableState<Boolean>, openSettings: () -> Unit) {
|
fun UserPicker(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||||
|
switchingUsers: MutableState<Boolean>,
|
||||||
|
showSettings: Boolean = true,
|
||||||
|
showCancel: Boolean = false,
|
||||||
|
cancelClicked: () -> Unit = {},
|
||||||
|
settingsClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var newChat by remember { mutableStateOf(userPickerState.value) }
|
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||||
val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } }
|
val users by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
chatModel.users
|
||||||
|
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||||
|
.sortedByDescending { it.user.activeUser }
|
||||||
|
}
|
||||||
|
}
|
||||||
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
launch {
|
launch {
|
||||||
@@ -90,27 +103,27 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.widthIn(min = 220.dp)
|
.widthIn(min = 260.dp)
|
||||||
.width(IntrinsicSize.Min)
|
.width(IntrinsicSize.Min)
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
.shadow(8.dp, MaterialTheme.shapes.medium, clip = false)
|
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
|
||||||
.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)
|
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
users.forEach { u ->
|
users.forEach { u ->
|
||||||
UserProfilePickerItem(u.user, u.unreadCount, openSettings = {
|
UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = {
|
||||||
openSettings()
|
settingsClicked()
|
||||||
userPickerState.value = AnimatedViewState.GONE
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
}) {
|
}) {
|
||||||
userPickerState.value = AnimatedViewState.HIDING
|
userPickerState.value = AnimatedViewState.HIDING
|
||||||
if (!u.user.activeUser) {
|
if (!u.user.activeUser) {
|
||||||
chatModel.chats.clear()
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val job = launch {
|
val job = launch {
|
||||||
delay(500)
|
delay(500)
|
||||||
switchingUsers.value = true
|
switchingUsers.value = true
|
||||||
}
|
}
|
||||||
chatModel.controller.changeActiveUser(u.user.userId)
|
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||||
job.cancel()
|
job.cancel()
|
||||||
switchingUsers.value = false
|
switchingUsers.value = false
|
||||||
}
|
}
|
||||||
@@ -120,16 +133,24 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
|
|||||||
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsPickerItem {
|
if (showSettings) {
|
||||||
openSettings()
|
SettingsPickerItem {
|
||||||
userPickerState.value = AnimatedViewState.GONE
|
settingsClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showCancel) {
|
||||||
|
CancelPickerItem {
|
||||||
|
cancelClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -140,59 +161,81 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = if (!u.activeUser) LocalIndication.current else null
|
indication = if (!u.activeUser) LocalIndication.current else null
|
||||||
)
|
)
|
||||||
.padding(PaddingValues(start = 8.dp, end = DEFAULT_PADDING)),
|
.padding(padding),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
UserProfileRow(u)
|
||||||
Modifier
|
|
||||||
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
ProfileImage(
|
|
||||||
image = u.image,
|
|
||||||
size = 54.dp
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
u.displayName,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 8.dp, end = 8.dp),
|
|
||||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (u.activeUser) {
|
if (u.activeUser) {
|
||||||
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
Icon(painterResource(R.drawable.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
} else if (u.hidden) {
|
||||||
|
Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
} else if (unreadCount > 0) {
|
} else if (unreadCount > 0) {
|
||||||
Row {
|
Box(
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
unreadCountStr(unreadCount),
|
unreadCountStr(unreadCount),
|
||||||
color = MaterialTheme.colors.onPrimary,
|
color = Color.White,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
.background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
.padding(2.dp)
|
||||||
.padding(horizontal = 3.dp)
|
.badgeLayout()
|
||||||
.padding(vertical = 1.dp),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!u.showNtfs) {
|
||||||
|
Icon(painterResource(R.drawable.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
} else {
|
||||||
Box(Modifier.size(20.dp))
|
Box(Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsPickerItem(onClick: () -> Unit) {
|
fun UserProfileRow(u: User) {
|
||||||
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {
|
Row(
|
||||||
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
Modifier
|
||||||
Text(
|
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||||
text,
|
.padding(vertical = 8.dp),
|
||||||
color = MaterialTheme.colors.onBackground,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ProfileImage(
|
||||||
|
image = u.image,
|
||||||
|
size = 54.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
u.displayName,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp, end = 8.dp),
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||||
|
Icon(painterResource(R.drawable.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.cancel_verb)
|
||||||
|
Icon(painterResource(R.drawable.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
)
|
)
|
||||||
Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,16 +13,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
@@ -57,20 +55,18 @@ fun ChatArchiveLayout(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(title)
|
AppBarTitle(title)
|
||||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.IosShare,
|
painterResource(R.drawable.ic_ios_share),
|
||||||
stringResource(R.string.save_archive),
|
stringResource(R.string.save_archive),
|
||||||
saveArchive,
|
saveArchive,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.delete_archive),
|
stringResource(R.string.delete_archive),
|
||||||
deleteArchiveAlert,
|
deleteArchiveAlert,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -81,6 +77,7 @@ fun ChatArchiveLayout(
|
|||||||
SectionTextFooter(
|
SectionTextFooter(
|
||||||
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
||||||
)
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +116,8 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
|
|||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "deleteArchiveAlert delete() error")
|
Log.e(TAG, "deleteArchiveAlert delete() error")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-31
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionItemViewSpaceBetween
|
import SectionItemViewSpaceBetween
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
@@ -11,27 +12,25 @@ import androidx.compose.foundation.shape.ZeroCornerSize
|
|||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.*
|
import androidx.compose.ui.text.input.*
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlin.math.log2
|
import kotlin.math.log2
|
||||||
|
|
||||||
@@ -41,9 +40,9 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
val prefs = m.controller.appPrefs
|
val prefs = m.controller.appPrefs
|
||||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||||
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
||||||
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
|
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
|
||||||
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
||||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
|
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
|
||||||
val newKey = rememberSaveable { mutableStateOf("") }
|
val newKey = rememberSaveable { mutableStateOf("") }
|
||||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
prefs.initialRandomDBPassphrase.set(false)
|
prefs.initialRandomDBPassphrase.set(false)
|
||||||
initialRandomDBPassphrase.value = false
|
initialRandomDBPassphrase.value = false
|
||||||
if (useKeychain.value) {
|
if (useKeychain.value) {
|
||||||
DatabaseUtils.setDatabaseKey(newKey.value)
|
DatabaseUtils.ksDatabasePassword.set(newKey.value)
|
||||||
}
|
}
|
||||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
@@ -113,7 +112,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,7 +135,6 @@ fun DatabaseEncryptionLayout(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.database_passphrase))
|
AppBarTitle(stringResource(R.string.database_passphrase))
|
||||||
SectionView(null) {
|
SectionView(null) {
|
||||||
@@ -149,7 +147,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||||
confirmText = generalGetString(R.string.remove_passphrase),
|
confirmText = generalGetString(R.string.remove_passphrase),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
setUseKeychain(false, useKeychain, prefs)
|
setUseKeychain(false, useKeychain, prefs)
|
||||||
storedKey.value = false
|
storedKey.value = false
|
||||||
},
|
},
|
||||||
@@ -161,7 +159,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
currentKey,
|
currentKey,
|
||||||
generalGetString(R.string.current_passphrase),
|
generalGetString(R.string.current_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -170,7 +168,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
newKey,
|
newKey,
|
||||||
generalGetString(R.string.new_passphrase),
|
generalGetString(R.string.new_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -201,7 +199,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
!validKey(newKey.value) ||
|
!validKey(newKey.value) ||
|
||||||
progressIndicator.value
|
progressIndicator.value
|
||||||
|
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
confirmNewKey,
|
confirmNewKey,
|
||||||
generalGetString(R.string.confirm_new_passphrase),
|
generalGetString(R.string.confirm_new_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -212,8 +210,8 @@ fun DatabaseEncryptionLayout(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
|
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +234,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +244,7 @@ fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
|
|||||||
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
||||||
confirmText = generalGetString(R.string.encrypt_database),
|
confirmText = generalGetString(R.string.encrypt_database),
|
||||||
onConfirm = onConfirm,
|
onConfirm = onConfirm,
|
||||||
destructive = false,
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +284,15 @@ fun SavePassphraseSetting(
|
|||||||
initialRandomDBPassphrase: Boolean,
|
initialRandomDBPassphrase: Boolean,
|
||||||
storedKey: Boolean,
|
storedKey: Boolean,
|
||||||
progressIndicator: Boolean,
|
progressIndicator: Boolean,
|
||||||
|
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
SectionItemView {
|
SectionItemView(minHeight = minHeight) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
|
if (storedKey) painterResource(R.drawable.ic_vpn_key_filled) else painterResource(R.drawable.ic_vpn_key_off_filled),
|
||||||
stringResource(R.string.save_passphrase_in_keychain),
|
stringResource(R.string.save_passphrase_in_keychain),
|
||||||
tint = if (storedKey) SimplexGreen else HighOrLowlight
|
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -301,13 +301,9 @@ fun SavePassphraseSetting(
|
|||||||
color = Color.Unspecified
|
color = Color.Unspecified
|
||||||
)
|
)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Switch(
|
DefaultSwitch(
|
||||||
checked = useKeychain,
|
checked = useKeychain,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -349,21 +345,22 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
|||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DatabaseKeyField(
|
fun PassphraseField(
|
||||||
key: MutableState<String>,
|
key: MutableState<String>,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showStrength: Boolean = false,
|
showStrength: Boolean = false,
|
||||||
isValid: (String) -> Boolean,
|
isValid: (String) -> Boolean,
|
||||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||||
|
dependsOn: State<Any?>? = null,
|
||||||
) {
|
) {
|
||||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||||
var showKey by remember { mutableStateOf(false) }
|
var showKey by remember { mutableStateOf(false) }
|
||||||
val icon = if (valid) {
|
val icon = if (valid) {
|
||||||
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
if (showKey) painterResource(R.drawable.ic_visibility_off_filled) else painterResource(R.drawable.ic_visibility_filled)
|
||||||
} else Icons.Outlined.Error
|
} else painterResource(R.drawable.ic_error)
|
||||||
val iconColor = if (valid) {
|
val iconColor = if (valid) {
|
||||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
|
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else MaterialTheme.colors.secondary
|
||||||
} else Color.Red
|
} else Color.Red
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val keyboardOptions = KeyboardOptions(
|
val keyboardOptions = KeyboardOptions(
|
||||||
@@ -420,7 +417,7 @@ fun DatabaseKeyField(
|
|||||||
TextFieldDefaults.TextFieldDecorationBox(
|
TextFieldDefaults.TextFieldDecorationBox(
|
||||||
value = state.value.text,
|
value = state.value.text,
|
||||||
innerTextField = innerTextField,
|
innerTextField = innerTextField,
|
||||||
placeholder = { Text(placeholder, color = HighOrLowlight) },
|
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
isError = !valid,
|
isError = !valid,
|
||||||
@@ -436,6 +433,13 @@ fun DatabaseKeyField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { dependsOn?.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(state.value.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||||
@@ -461,7 +465,7 @@ private fun passphraseEntropy(s: String): Double {
|
|||||||
return s.length * log2(poolSize.toDouble())
|
return s.length * log2(poolSize.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class PassphraseStrength(val color: Color) {
|
enum class PassphraseStrength(val color: Color) {
|
||||||
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+124
-69
@@ -1,9 +1,11 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
@@ -13,6 +15,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
@@ -20,6 +23,7 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.AppVersionText
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
@@ -35,94 +39,137 @@ fun DatabaseErrorView(
|
|||||||
) {
|
) {
|
||||||
val progressIndicator = remember { mutableStateOf(false) }
|
val progressIndicator = remember { mutableStateOf(false) }
|
||||||
val dbKey = remember { mutableStateOf("") }
|
val dbKey = remember { mutableStateOf("") }
|
||||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
|
var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) }
|
||||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||||
val saveAndRunChatOnClick: () -> Unit = {
|
|
||||||
DatabaseUtils.setDatabaseKey(dbKey.value)
|
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||||
|
val useKey = if (useKeychain) null else dbKey.value
|
||||||
|
runChat(useKey, confirmMigrations, chatDbStatus, progressIndicator, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndRunChatOnClick() {
|
||||||
|
DatabaseUtils.ksDatabasePassword.set(dbKey.value)
|
||||||
storedDBKey = dbKey.value
|
storedDBKey = dbKey.value
|
||||||
appPreferences.storeDBPassphrase.set(true)
|
appPreferences.storeDBPassphrase.set(true)
|
||||||
useKeychain = true
|
useKeychain = true
|
||||||
appPreferences.initialRandomDBPassphrase.set(false)
|
appPreferences.initialRandomDBPassphrase.set(false)
|
||||||
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
callRunChat()
|
||||||
}
|
}
|
||||||
val title = when (chatDbStatus.value) {
|
|
||||||
is DBMigrationResult.OK -> ""
|
@Composable
|
||||||
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
|
fun DatabaseErrorDetails(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
|
||||||
generalGetString(R.string.wrong_passphrase)
|
Text(
|
||||||
else
|
generalGetString(title),
|
||||||
generalGetString(R.string.encrypted_database)
|
Modifier.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
||||||
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
|
style = MaterialTheme.typography.h1
|
||||||
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
|
)
|
||||||
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
|
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content)
|
||||||
null -> "" // should never be here
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileNameText(dbFile: String) {
|
||||||
|
Text(String.format(generalGetString(R.string.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MigrationsText(ms: List<String>) {
|
||||||
|
Text(String.format(generalGetString(R.string.database_migrations), ms.joinToString(", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||||
title,
|
when (val status = chatDbStatus.value) {
|
||||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
|
is DBMigrationResult.ErrorNotADatabase ->
|
||||||
style = MaterialTheme.typography.h1
|
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||||
)
|
DatabaseErrorDetails(R.string.wrong_passphrase) {
|
||||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
|
||||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
|
||||||
when (val status = chatDbStatus.value) {
|
|
||||||
is DBMigrationResult.ErrorNotADatabase -> {
|
|
||||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
|
||||||
Text(generalGetString(R.string.passphrase_is_different))
|
Text(generalGetString(R.string.passphrase_is_different))
|
||||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||||
saveAndRunChatOnClick()
|
saveAndRunChatOnClick()
|
||||||
}
|
}
|
||||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
FileNameText(status.dbFile)
|
||||||
} else {
|
}
|
||||||
|
} else {
|
||||||
|
DatabaseErrorDetails(R.string.encrypted_database) {
|
||||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
|
||||||
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
|
||||||
}
|
|
||||||
if (useKeychain) {
|
if (useKeychain) {
|
||||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
DatabaseKeyField(dbKey, buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
|
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
} else {
|
} else {
|
||||||
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
|
DatabaseKeyField(dbKey, buttonEnabled) { callRunChat() }
|
||||||
|
OpenChatButton(buttonEnabled) { callRunChat() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DBMigrationResult.Error -> {
|
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
is MigrationError.Upgrade ->
|
||||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
|
DatabaseErrorDetails(R.string.database_upgrade) {
|
||||||
|
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||||
|
Text(generalGetString(R.string.upgrade_and_open_chat))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
MigrationsText(err.upMigrations.map { it.upName })
|
||||||
|
AppVersionText()
|
||||||
|
}
|
||||||
|
is MigrationError.Downgrade ->
|
||||||
|
DatabaseErrorDetails(R.string.database_downgrade) {
|
||||||
|
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||||
|
Text(generalGetString(R.string.downgrade_and_open_chat))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(generalGetString(R.string.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
MigrationsText(err.downMigrations)
|
||||||
|
AppVersionText()
|
||||||
|
}
|
||||||
|
is MigrationError.Error ->
|
||||||
|
DatabaseErrorDetails(R.string.incompatible_database_version) {
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
Text(String.format(generalGetString(R.string.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DBMigrationResult.ErrorSQL ->
|
||||||
|
DatabaseErrorDetails(R.string.database_error) {
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
Text(String.format(generalGetString(R.string.error_with_info), status.migrationSQLError))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.ErrorKeychain -> {
|
is DBMigrationResult.ErrorKeychain ->
|
||||||
|
DatabaseErrorDetails(R.string.keychain_error) {
|
||||||
Text(generalGetString(R.string.cannot_access_keychain))
|
Text(generalGetString(R.string.cannot_access_keychain))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.Unknown -> {
|
is DBMigrationResult.InvalidConfirmation ->
|
||||||
|
DatabaseErrorDetails(R.string.invalid_migration_confirmation) {
|
||||||
|
// this can only happen if incorrect parameter is passed
|
||||||
|
}
|
||||||
|
is DBMigrationResult.Unknown ->
|
||||||
|
DatabaseErrorDetails(R.string.database_error) {
|
||||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.OK -> {
|
is DBMigrationResult.OK -> {}
|
||||||
}
|
null -> {}
|
||||||
null -> {
|
}
|
||||||
}
|
if (restoreDbFromBackup.value) {
|
||||||
}
|
SectionSpacer()
|
||||||
if (restoreDbFromBackup.value) {
|
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||||
SectionSpacer()
|
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
RestoreDbButton {
|
||||||
Spacer(Modifier.size(16.dp))
|
AlertManager.shared.showAlertDialog(
|
||||||
RestoreDbButton {
|
title = generalGetString(R.string.restore_database_alert_title),
|
||||||
AlertManager.shared.showAlertDialog(
|
text = generalGetString(R.string.restore_database_alert_desc),
|
||||||
title = generalGetString(R.string.restore_database_alert_title),
|
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||||
text = generalGetString(R.string.restore_database_alert_desc),
|
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
destructive = true,
|
||||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
)
|
||||||
destructive = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
if (progressIndicator.value) {
|
if (progressIndicator.value) {
|
||||||
Box(
|
Box(
|
||||||
@@ -133,7 +180,7 @@ fun DatabaseErrorView(
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,7 +188,8 @@ fun DatabaseErrorView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runChat(
|
private fun runChat(
|
||||||
dbKey: String,
|
dbKey: String? = null,
|
||||||
|
confirmMigrations: MigrationConfirmation? = null,
|
||||||
chatDbStatus: State<DBMigrationResult?>,
|
chatDbStatus: State<DBMigrationResult?>,
|
||||||
progressIndicator: MutableState<Boolean>,
|
progressIndicator: MutableState<Boolean>,
|
||||||
prefs: AppPreferences
|
prefs: AppPreferences
|
||||||
@@ -150,7 +198,7 @@ private fun runChat(
|
|||||||
if (progressIndicator.value) return@launch
|
if (progressIndicator.value) return@launch
|
||||||
progressIndicator.value = true
|
progressIndicator.value = true
|
||||||
try {
|
try {
|
||||||
SimplexApp.context.initChatController(dbKey)
|
SimplexApp.context.initChatController(dbKey, confirmMigrations)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||||
}
|
}
|
||||||
@@ -163,18 +211,17 @@ private fun runChat(
|
|||||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DBMigrationResult.ErrorNotADatabase -> {
|
is DBMigrationResult.ErrorNotADatabase ->
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||||
}
|
is DBMigrationResult.ErrorSQL ->
|
||||||
is DBMigrationResult.Error -> {
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationSQLError)
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
|
is DBMigrationResult.ErrorKeychain ->
|
||||||
}
|
|
||||||
is DBMigrationResult.ErrorKeychain -> {
|
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||||
}
|
is DBMigrationResult.Unknown ->
|
||||||
is DBMigrationResult.Unknown -> {
|
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||||
}
|
is DBMigrationResult.InvalidConfirmation ->
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.invalid_migration_confirmation))
|
||||||
|
is DBMigrationResult.ErrorMigration -> {}
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,9 +251,17 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mtrErrorDescription(err: MTRError): String =
|
||||||
|
when (err) {
|
||||||
|
is MTRError.NoDown ->
|
||||||
|
String.format(generalGetString(R.string.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||||
|
is MTRError.Different ->
|
||||||
|
String.format(generalGetString(R.string.mtr_error_different), err.appMigration, err.dbMigration)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
text,
|
text,
|
||||||
generalGetString(R.string.enter_passphrase),
|
generalGetString(R.string.enter_passphrase),
|
||||||
isValid = ::validKey,
|
isValid = ::validKey,
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.FileUtils
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
@@ -18,14 +17,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -40,6 +37,7 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.usersettings.*
|
import chat.simplex.app.views.usersettings.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.datetime.*
|
import kotlinx.datetime.*
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -52,7 +50,7 @@ fun DatabaseView(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val progressIndicator = remember { mutableStateOf(false) }
|
val progressIndicator = remember { mutableStateOf(false) }
|
||||||
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
|
val runChat = remember { m.chatRunning }
|
||||||
val prefs = m.controller.appPrefs
|
val prefs = m.controller.appPrefs
|
||||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||||
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
||||||
@@ -76,7 +74,7 @@ fun DatabaseView(
|
|||||||
) {
|
) {
|
||||||
DatabaseLayout(
|
DatabaseLayout(
|
||||||
progressIndicator.value,
|
progressIndicator.value,
|
||||||
runChat.value,
|
runChat.value != false,
|
||||||
m.chatDbChanged.value,
|
m.chatDbChanged.value,
|
||||||
useKeychain.value,
|
useKeychain.value,
|
||||||
m.chatDbEncrypted.value,
|
m.chatDbEncrypted.value,
|
||||||
@@ -116,7 +114,7 @@ fun DatabaseView(
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,13 +152,12 @@ fun DatabaseLayout(
|
|||||||
val operationsDisabled = !stopped || progressIndicator
|
val operationsDisabled = !stopped || progressIndicator
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 48.dp),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.your_chat_database))
|
AppBarTitle(stringResource(R.string.your_chat_database))
|
||||||
|
|
||||||
SectionView(stringResource(R.string.messages_section_title).uppercase()) {
|
SectionView(stringResource(R.string.messages_section_title).uppercase()) {
|
||||||
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
|
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
|
||||||
}
|
}
|
||||||
SectionTextFooter(
|
SectionTextFooter(
|
||||||
remember(currentUser?.displayName) {
|
remember(currentUser?.displayName) {
|
||||||
@@ -173,27 +170,28 @@ fun DatabaseLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(stringResource(R.string.run_chat_section)) {
|
SectionView(stringResource(R.string.run_chat_section)) {
|
||||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced()
|
||||||
|
|
||||||
SectionView(stringResource(R.string.chat_database_section)) {
|
SectionView(stringResource(R.string.chat_database_section)) {
|
||||||
val unencrypted = chatDbEncrypted == false
|
val unencrypted = chatDbEncrypted == false
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
|
if (unencrypted) painterResource(R.drawable.ic_lock_open) else if (useKeyChain) painterResource(R.drawable.ic_vpn_key_filled)
|
||||||
|
else painterResource(R
|
||||||
|
.drawable.ic_lock),
|
||||||
stringResource(R.string.database_passphrase),
|
stringResource(R.string.database_passphrase),
|
||||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||||
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
|
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
|
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
|
||||||
SectionDivider()
|
SectionDividerSpaced(maxBottomPadding = false)
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.IosShare,
|
painterResource(R.drawable.ic_ios_share),
|
||||||
stringResource(R.string.export_database),
|
stringResource(R.string.export_database),
|
||||||
click = {
|
click = {
|
||||||
if (initialRandomDBPassphrase.get()) {
|
if (initialRandomDBPassphrase.get()) {
|
||||||
@@ -206,31 +204,28 @@ fun DatabaseLayout(
|
|||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.FileDownload,
|
painterResource(R.drawable.ic_download),
|
||||||
stringResource(R.string.import_database),
|
stringResource(R.string.import_database),
|
||||||
{ importArchiveLauncher.launch("application/zip") },
|
{ importArchiveLauncher.launch("application/zip") },
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
iconColor = Color.Red,
|
iconColor = Color.Red,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
val chatArchiveNameVal = chatArchiveName.value
|
val chatArchiveNameVal = chatArchiveName.value
|
||||||
val chatArchiveTimeVal = chatArchiveTime.value
|
val chatArchiveTimeVal = chatArchiveTime.value
|
||||||
val chatLastStartVal = chatLastStart.value
|
val chatLastStartVal = chatLastStart.value
|
||||||
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||||
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Inventory2,
|
painterResource(R.drawable.ic_inventory_2),
|
||||||
title,
|
title,
|
||||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
}
|
}
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.DeleteForever,
|
painterResource(R.drawable.ic_delete_forever),
|
||||||
stringResource(R.string.delete_database),
|
stringResource(R.string.delete_database),
|
||||||
deleteChatAlert,
|
deleteChatAlert,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -245,7 +240,7 @@ fun DatabaseLayout(
|
|||||||
stringResource(R.string.stop_chat_to_enable_database_actions)
|
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(stringResource(R.string.files_and_media_section).uppercase()) {
|
SectionView(stringResource(R.string.files_and_media_section).uppercase()) {
|
||||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||||
@@ -255,7 +250,7 @@ fun DatabaseLayout(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(if (users.size > 1) R.string.delete_files_and_media_for_all_users else R.string.delete_files_and_media_all),
|
stringResource(if (users.size > 1) R.string.delete_files_and_media_for_all_users else R.string.delete_files_and_media_all),
|
||||||
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
|
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,35 +262,23 @@ fun DatabaseLayout(
|
|||||||
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
|
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
|
||||||
SectionItemView {
|
SettingsPreferenceItem(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
painterResource(R.drawable.ic_backup),
|
||||||
Icon(Icons.Outlined.Backup, stringResource(R.string.full_backup), tint = HighOrLowlight)
|
iconColor = MaterialTheme.colors.secondary,
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
pref = privacyFullBackup,
|
||||||
val prefState = remember { mutableStateOf(privacyFullBackup.get()) }
|
text = stringResource(R.string.full_backup)
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
) {
|
||||||
Text(stringResource(R.string.full_backup), Modifier.padding(end = 24.dp))
|
if (initialRandomDBPassphrase.get()) {
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
exportProhibitedAlert()
|
||||||
Switch(
|
privacyFullBackup.set(false)
|
||||||
checked = prefState.value,
|
} else {
|
||||||
onCheckedChange = {
|
privacyFullBackup.set(it)
|
||||||
if (initialRandomDBPassphrase.get()) {
|
|
||||||
exportProhibitedAlert()
|
|
||||||
} else {
|
|
||||||
privacyFullBackup.set(it)
|
|
||||||
prefState.value = it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,7 +294,8 @@ private fun setChatItemTTLAlert(
|
|||||||
text = generalGetString(R.string.enable_automatic_deletion_message),
|
text = generalGetString(R.string.enable_automatic_deletion_message),
|
||||||
confirmText = generalGetString(R.string.delete_messages),
|
confirmText = generalGetString(R.string.delete_messages),
|
||||||
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
|
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
|
||||||
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }
|
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,36 +334,23 @@ fun RunChatSetting(
|
|||||||
startChat: () -> Unit,
|
startChat: () -> Unit,
|
||||||
stopChatAlert: () -> Unit
|
stopChatAlert: () -> Unit
|
||||||
) {
|
) {
|
||||||
SectionItemView() {
|
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
SettingsActionItemWithContent(
|
||||||
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
icon = if (stopped) painterResource(R.drawable.ic_report_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
Icon(
|
text = chatRunningText,
|
||||||
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
|
iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary,
|
||||||
chatRunningText,
|
) {
|
||||||
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
|
DefaultSwitch(
|
||||||
)
|
enabled = !chatDbDeleted,
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
checked = runChat,
|
||||||
Text(
|
onCheckedChange = { runChatSwitch ->
|
||||||
chatRunningText,
|
if (runChatSwitch) {
|
||||||
Modifier.padding(end = 24.dp)
|
startChat()
|
||||||
)
|
} else {
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
stopChatAlert()
|
||||||
Switch(
|
}
|
||||||
enabled = !chatDbDeleted,
|
},
|
||||||
checked = runChat,
|
)
|
||||||
onCheckedChange = { runChatSwitch ->
|
|
||||||
if (runChatSwitch) {
|
|
||||||
startChat()
|
|
||||||
} else {
|
|
||||||
stopChatAlert()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +359,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
|||||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||||
withApi {
|
withApi {
|
||||||
try {
|
try {
|
||||||
if (chatDbChanged.value) {
|
if (chatDbChanged.value) {
|
||||||
@@ -417,7 +388,7 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
AlertManager.shared.showAlertDialog(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.stop_chat_question),
|
title = generalGetString(R.string.stop_chat_question),
|
||||||
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||||
@@ -434,7 +405,7 @@ private fun exportProhibitedAlert() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
if (m.controller.appPrefs.performLA.get()) {
|
if (m.controller.appPrefs.performLA.get()) {
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_stop_chat),
|
generalGetString(R.string.auth_stop_chat),
|
||||||
@@ -442,12 +413,13 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
|||||||
context as FragmentActivity,
|
context as FragmentActivity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
LAResult.Success, LAResult.Unavailable -> {
|
LAResult.Success, is LAResult.Unavailable -> {
|
||||||
stopChat(m, runChat, context)
|
stopChat(m, runChat, context)
|
||||||
}
|
}
|
||||||
is LAResult.Error -> {
|
is LAResult.Error -> {
|
||||||
|
runChat.value = true
|
||||||
}
|
}
|
||||||
LAResult.Failed -> {
|
is LAResult.Failed -> {
|
||||||
runChat.value = true
|
runChat.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +430,7 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
withApi {
|
withApi {
|
||||||
try {
|
try {
|
||||||
m.controller.apiStopChat()
|
m.controller.apiStopChat()
|
||||||
@@ -572,7 +544,8 @@ private fun importArchiveAlert(
|
|||||||
title = generalGetString(R.string.import_database_question),
|
title = generalGetString(R.string.import_database_question),
|
||||||
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||||
confirmText = generalGetString(R.string.import_database_confirmation),
|
confirmText = generalGetString(R.string.import_database_confirmation),
|
||||||
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) }
|
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +565,7 @@ private fun importArchive(
|
|||||||
try {
|
try {
|
||||||
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||||
m.controller.apiImportArchive(config)
|
m.controller.apiImportArchive(config)
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
||||||
@@ -620,7 +593,7 @@ private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): Strin
|
|||||||
if (inputStream != null && archiveName != null) {
|
if (inputStream != null && archiveName != null) {
|
||||||
val archivePath = "${context.cacheDir}/$archiveName"
|
val archivePath = "${context.cacheDir}/$archiveName"
|
||||||
val destFile = File(archivePath)
|
val destFile = File(archivePath)
|
||||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||||
archivePath
|
archivePath
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||||
@@ -637,7 +610,8 @@ private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolea
|
|||||||
title = generalGetString(R.string.delete_chat_profile_question),
|
title = generalGetString(R.string.delete_chat_profile_question),
|
||||||
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
onConfirm = { deleteChat(m, progressIndicator) }
|
onConfirm = { deleteChat(m, progressIndicator) },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,7 +621,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
|||||||
try {
|
try {
|
||||||
m.controller.apiDeleteStorage()
|
m.controller.apiDeleteStorage()
|
||||||
m.chatDbDeleted.value = true
|
m.chatDbDeleted.value = true
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
@@ -33,13 +35,13 @@ class AlertManager {
|
|||||||
text: String? = null,
|
text: String? = null,
|
||||||
buttons: @Composable () -> Unit,
|
buttons: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = this::hideAlert,
|
onDismissRequest = this::hideAlert,
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
buttons = buttons
|
buttons = buttons,
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,22 +53,21 @@ class AlertManager {
|
|||||||
) {
|
) {
|
||||||
showAlert {
|
showAlert {
|
||||||
Dialog(onDismissRequest = this::hideAlert) {
|
Dialog(onDismissRequest = this::hideAlert) {
|
||||||
Column(Modifier.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)) {
|
Column(
|
||||||
Text(title,
|
Modifier
|
||||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = if (text == null) DEFAULT_PADDING else DEFAULT_PADDING_HALF),
|
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
fontSize = 15.sp,
|
.padding(bottom = DEFAULT_PADDING)
|
||||||
fontWeight = FontWeight.SemiBold
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 20.sp
|
||||||
)
|
)
|
||||||
if (text != null) {
|
|
||||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||||
|
if (text != null) {
|
||||||
|
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
buttons()
|
buttons()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,24 +85,27 @@ class AlertManager {
|
|||||||
onDismissRequest: (() -> Unit)? = null,
|
onDismissRequest: (() -> Unit)? = null,
|
||||||
destructive: Boolean = false
|
destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
confirmButton = {
|
buttons = {
|
||||||
TextButton(onClick = {
|
Row (
|
||||||
onConfirm?.invoke()
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||||
hideAlert()
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(dismissText) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
onConfirm?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
TextButton(onClick = {
|
|
||||||
onDismiss?.invoke()
|
|
||||||
hideAlert()
|
|
||||||
}) { Text(dismissText) }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,16 +120,15 @@ class AlertManager {
|
|||||||
onDismissRequest: (() -> Unit)? = null,
|
onDismissRequest: (() -> Unit)? = null,
|
||||||
destructive: Boolean = false
|
destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
buttons = {
|
buttons = {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp),
|
||||||
horizontalAlignment = Alignment.End
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
onDismiss?.invoke()
|
onDismiss?.invoke()
|
||||||
@@ -134,39 +137,42 @@ class AlertManager {
|
|||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
onConfirm?.invoke()
|
onConfirm?.invoke()
|
||||||
hideAlert()
|
hideAlert()
|
||||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
}) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAlertMsg(
|
fun showAlertMsg(
|
||||||
title: String, text: String? = null,
|
title: String, text: String? = null,
|
||||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
confirmText: String = generalGetString(R.string.ok)
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = this::hideAlert,
|
onDismissRequest = this::hideAlert,
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
confirmButton = {
|
buttons = {
|
||||||
TextButton(onClick = {
|
Row(
|
||||||
onConfirm?.invoke()
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||||
hideAlert()
|
horizontalArrangement = Arrangement.Center
|
||||||
}) { Text(confirmText) }
|
) {
|
||||||
}
|
TextButton(onClick = {
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = Color.Unspecified) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAlertMsg(
|
fun showAlertMsg(
|
||||||
title: Int,
|
title: Int,
|
||||||
text: Int? = null,
|
text: Int? = null,
|
||||||
confirmText: Int = R.string.ok,
|
confirmText: Int = R.string.ok,
|
||||||
onConfirm: (() -> Unit)? = null
|
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText))
|
||||||
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun showInView() {
|
fun showInView() {
|
||||||
@@ -177,3 +183,30 @@ class AlertManager {
|
|||||||
val shared = AlertManager()
|
val shared = AlertManager()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun alertTitle(title: String): (@Composable () -> Unit)? {
|
||||||
|
return {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertText(text: String?): (@Composable () -> Unit)? {
|
||||||
|
return if (text == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
({
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -23,18 +20,18 @@ import chat.simplex.app.model.ChatInfo
|
|||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
val icon =
|
val icon =
|
||||||
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
if (chatInfo is ChatInfo.Group) R.drawable.ic_supervised_user_circle_filled
|
||||||
else Icons.Filled.AccountCircle
|
else R.drawable.ic_account_circle_filled
|
||||||
ProfileImage(size, chatInfo.image, icon, iconColor)
|
ProfileImage(size, chatInfo.image, icon, iconColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
Box(Modifier.size(size)) {
|
Box(Modifier.size(size)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
|
painterResource(R.drawable.ic_theater_comedy_filled), stringResource(R.string.incognito),
|
||||||
modifier = Modifier.size(size).padding(size / 12),
|
modifier = Modifier.size(size).padding(size / 12),
|
||||||
iconColor
|
iconColor
|
||||||
)
|
)
|
||||||
@@ -45,17 +42,31 @@ fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary)
|
|||||||
fun ProfileImage(
|
fun ProfileImage(
|
||||||
size: Dp,
|
size: Dp,
|
||||||
image: String? = null,
|
image: String? = null,
|
||||||
icon: ImageVector = Icons.Filled.AccountCircle,
|
icon: Int = R.drawable.ic_account_circle_filled,
|
||||||
color: Color = MaterialTheme.colors.secondary
|
color: Color = MaterialTheme.colors.secondaryVariant
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(size)) {
|
Box(Modifier.size(size)) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
Icon(
|
val iconToReplace = when (icon) {
|
||||||
icon,
|
R.drawable.ic_account_circle_filled -> AccountCircleFilled
|
||||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
R.drawable.ic_supervised_user_circle_filled -> SupervisedUserCircleFilled
|
||||||
tint = color,
|
else -> null
|
||||||
modifier = Modifier.fillMaxSize()
|
}
|
||||||
)
|
if (iconToReplace != null) {
|
||||||
|
Icon(
|
||||||
|
iconToReplace,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painterResource(icon),
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
@@ -68,6 +79,7 @@ fun ProfileImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewChatInfoImage() {
|
fun PreviewChatInfoImage() {
|
||||||
|
|||||||
+18
-11
@@ -1,21 +1,24 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.views.newchat.ActionButton
|
import chat.simplex.app.views.newchat.ActionButton
|
||||||
|
|
||||||
sealed class AttachmentOption {
|
sealed class AttachmentOption {
|
||||||
object TakePhoto: AttachmentOption()
|
object CameraPhoto: AttachmentOption()
|
||||||
object PickImage: AttachmentOption()
|
object GalleryImage: AttachmentOption()
|
||||||
object PickFile: AttachmentOption()
|
object GalleryVideo: AttachmentOption()
|
||||||
|
object File: AttachmentOption()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,16 +40,20 @@ fun ChooseAttachmentView(
|
|||||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(R.string.use_camera_button), icon = painterResource(R.drawable.ic_camera_enhance)) {
|
||||||
attachmentOption.value = AttachmentOption.TakePhoto
|
attachmentOption.value = AttachmentOption.CameraPhoto
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(R.string.gallery_image_button), icon = painterResource(R.drawable.ic_add_photo)) {
|
||||||
attachmentOption.value = AttachmentOption.PickImage
|
attachmentOption.value = AttachmentOption.GalleryImage
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(R.string.gallery_video_button), icon = painterResource(R.drawable.ic_smart_display)) {
|
||||||
attachmentOption.value = AttachmentOption.PickFile
|
attachmentOption.value = AttachmentOption.GalleryVideo
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(R.string.choose_file), icon = painterResource(R.drawable.ic_note_add)) {
|
||||||
|
attachmentOption.value = AttachmentOption.File
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit = {}) {
|
fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -22,8 +27,12 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||||
content = {
|
content = {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(
|
||||||
NavigationButtonBack(close)
|
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
NavigationButtonBack(onButtonClicked = close)
|
||||||
Row {
|
Row {
|
||||||
endButtons()
|
endButtons()
|
||||||
}
|
}
|
||||||
@@ -35,17 +44,21 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||||
val padding = if (withPadding)
|
val theme = CurrentColors.collectAsState()
|
||||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
val titleColor = CurrentColors.collectAsState().value.appColors.title
|
||||||
else
|
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
|
||||||
PaddingValues(bottom = DEFAULT_PADDING)
|
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||||
|
else // color is not updated when changing themes if I pass null here
|
||||||
|
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(padding),
|
.padding(bottom = DEFAULT_PADDING * 1.5f, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,),
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.h1
|
style = MaterialTheme.typography.h1.copy(brush = brush),
|
||||||
|
color = MaterialTheme.colors.primaryVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.materialIcon
|
||||||
|
import androidx.compose.material.icons.materialPath
|
||||||
|
import androidx.compose.ui.graphics.vector.*
|
||||||
|
|
||||||
|
val AccountCircleFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_accountCircleFilled != null) {
|
||||||
|
return _accountCircleFilled!!
|
||||||
|
}
|
||||||
|
_accountCircleFilled = materialIcon(name = "Filled.AccountCircle") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(12.0f, 2.0f)
|
||||||
|
curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f)
|
||||||
|
reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f)
|
||||||
|
reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 5.0f)
|
||||||
|
curveToRelative(1.66f, 0.0f, 3.0f, 1.34f, 3.0f, 3.0f)
|
||||||
|
reflectiveCurveToRelative(-1.34f, 3.0f, -3.0f, 3.0f)
|
||||||
|
reflectiveCurveToRelative(-3.0f, -1.34f, -3.0f, -3.0f)
|
||||||
|
reflectiveCurveToRelative(1.34f, -3.0f, 3.0f, -3.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 19.2f)
|
||||||
|
curveToRelative(-2.5f, 0.0f, -4.71f, -1.28f, -6.0f, -3.22f)
|
||||||
|
curveToRelative(0.03f, -1.99f, 4.0f, -3.08f, 6.0f, -3.08f)
|
||||||
|
curveToRelative(1.99f, 0.0f, 5.97f, 1.09f, 6.0f, 3.08f)
|
||||||
|
curveToRelative(-1.29f, 1.94f, -3.5f, 3.22f, -6.0f, 3.22f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _accountCircleFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _accountCircleFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val SupervisedUserCircleFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_supervisedUserCircleFilled != null) {
|
||||||
|
return _supervisedUserCircleFilled!!
|
||||||
|
}
|
||||||
|
_supervisedUserCircleFilled = materialIcon(name = "Filled.SupervisedUserCircle") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(11.99f, 2.0f)
|
||||||
|
curveToRelative(-5.52f, 0.0f, -10.0f, 4.48f, -10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f)
|
||||||
|
reflectiveCurveToRelative(-4.48f, -10.0f, -10.0f, -10.0f)
|
||||||
|
close()
|
||||||
|
moveTo(15.6f, 8.34f)
|
||||||
|
curveToRelative(1.07f, 0.0f, 1.93f, 0.86f, 1.93f, 1.93f)
|
||||||
|
curveToRelative(0.0f, 1.07f, -0.86f, 1.93f, -1.93f, 1.93f)
|
||||||
|
curveToRelative(-1.07f, 0.0f, -1.93f, -0.86f, -1.93f, -1.93f)
|
||||||
|
curveToRelative(-0.01f, -1.07f, 0.86f, -1.93f, 1.93f, -1.93f)
|
||||||
|
close()
|
||||||
|
moveTo(9.6f, 6.76f)
|
||||||
|
curveToRelative(1.3f, 0.0f, 2.36f, 1.06f, 2.36f, 2.36f)
|
||||||
|
curveToRelative(0.0f, 1.3f, -1.06f, 2.36f, -2.36f, 2.36f)
|
||||||
|
reflectiveCurveToRelative(-2.36f, -1.06f, -2.36f, -2.36f)
|
||||||
|
curveToRelative(0.0f, -1.31f, 1.05f, -2.36f, 2.36f, -2.36f)
|
||||||
|
close()
|
||||||
|
moveTo(9.6f, 15.89f)
|
||||||
|
verticalLineToRelative(3.75f)
|
||||||
|
curveToRelative(-2.4f, -0.75f, -4.3f, -2.6f, -5.14f, -4.96f)
|
||||||
|
curveToRelative(1.05f, -1.12f, 3.67f, -1.69f, 5.14f, -1.69f)
|
||||||
|
curveToRelative(0.53f, 0.0f, 1.2f, 0.08f, 1.9f, 0.22f)
|
||||||
|
curveToRelative(-1.64f, 0.87f, -1.9f, 2.02f, -1.9f, 2.68f)
|
||||||
|
close()
|
||||||
|
moveTo(11.99f, 20.0f)
|
||||||
|
curveToRelative(-0.27f, 0.0f, -0.53f, -0.01f, -0.79f, -0.04f)
|
||||||
|
verticalLineToRelative(-4.07f)
|
||||||
|
curveToRelative(0.0f, -1.42f, 2.94f, -2.13f, 4.4f, -2.13f)
|
||||||
|
curveToRelative(1.07f, 0.0f, 2.92f, 0.39f, 3.84f, 1.15f)
|
||||||
|
curveToRelative(-1.17f, 2.97f, -4.06f, 5.09f, -7.45f, 5.09f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _supervisedUserCircleFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _supervisedUserCircleFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val BoltFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_boltFilled != null) {
|
||||||
|
return _boltFilled!!
|
||||||
|
}
|
||||||
|
_boltFilled = materialIcon(name = "Filled.Bolt") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(11.0f, 21.0f)
|
||||||
|
horizontalLineToRelative(-1.0f)
|
||||||
|
lineToRelative(1.0f, -7.0f)
|
||||||
|
horizontalLineTo(7.5f)
|
||||||
|
curveToRelative(-0.58f, 0.0f, -0.57f, -0.32f, -0.38f, -0.66f)
|
||||||
|
curveToRelative(0.19f, -0.34f, 0.05f, -0.08f, 0.07f, -0.12f)
|
||||||
|
curveTo(8.48f, 10.94f, 10.42f, 7.54f, 13.0f, 3.0f)
|
||||||
|
horizontalLineToRelative(1.0f)
|
||||||
|
lineToRelative(-1.0f, 7.0f)
|
||||||
|
horizontalLineToRelative(3.5f)
|
||||||
|
curveToRelative(0.49f, 0.0f, 0.56f, 0.33f, 0.47f, 0.51f)
|
||||||
|
lineToRelative(-0.07f, 0.15f)
|
||||||
|
curveTo(12.96f, 17.55f, 11.0f, 21.0f, 11.0f, 21.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _boltFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _boltFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val MoreVertFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_moreVertFilled != null) {
|
||||||
|
return _moreVertFilled!!
|
||||||
|
}
|
||||||
|
_moreVertFilled = materialIcon(name = "Filled.MoreVert") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(12.0f, 8.0f)
|
||||||
|
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 10.0f)
|
||||||
|
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 16.0f)
|
||||||
|
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _moreVertFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _moreVertFilled: ImageVector? = null
|
||||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
|
import chat.simplex.app.model.SharedPreference
|
||||||
import chat.simplex.app.views.usersettings.Cryptor
|
import chat.simplex.app.views.usersettings.Cryptor
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -16,30 +17,36 @@ object DatabaseUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
||||||
|
private const val APP_PASSWORD_ALIAS: String = "appPassword"
|
||||||
|
|
||||||
|
val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase)
|
||||||
|
val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase)
|
||||||
|
|
||||||
|
class KeyStoreItem(private val alias: String, val passphrase: SharedPreference<String?>, val initVector: SharedPreference<String?>) {
|
||||||
|
fun get(): String? {
|
||||||
|
return cryptor.decryptData(
|
||||||
|
passphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||||
|
initVector.get()?.toByteArrayFromBase64() ?: return null,
|
||||||
|
alias,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(key: String) {
|
||||||
|
val data = cryptor.encryptText(key, alias)
|
||||||
|
passphrase.set(data.first.toBase64String())
|
||||||
|
initVector.set(data.second.toBase64String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove() {
|
||||||
|
cryptor.deleteKey(alias)
|
||||||
|
passphrase.set(null)
|
||||||
|
initVector.set(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasDatabase(rootDir: String): Boolean =
|
private fun hasDatabase(rootDir: String): Boolean =
|
||||||
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
||||||
|
|
||||||
fun getDatabaseKey(): String? {
|
|
||||||
return cryptor.decryptData(
|
|
||||||
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
|
||||||
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
|
||||||
DATABASE_PASSWORD_ALIAS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDatabaseKey(key: String) {
|
|
||||||
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
|
|
||||||
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
|
|
||||||
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeDatabaseKey() {
|
|
||||||
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
|
|
||||||
appPreferences.encryptedDBPassphrase.set(null)
|
|
||||||
appPreferences.initializationVectorDBPassphrase.set(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useDatabaseKey(): String {
|
fun useDatabaseKey(): String {
|
||||||
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
||||||
var dbKey = ""
|
var dbKey = ""
|
||||||
@@ -47,10 +54,10 @@ object DatabaseUtils {
|
|||||||
if (useKeychain) {
|
if (useKeychain) {
|
||||||
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
||||||
dbKey = randomDatabasePassword()
|
dbKey = randomDatabasePassword()
|
||||||
setDatabaseKey(dbKey)
|
ksDatabasePassword.set(dbKey)
|
||||||
appPreferences.initialRandomDBPassphrase.set(true)
|
appPreferences.initialRandomDBPassphrase.set(true)
|
||||||
} else {
|
} else {
|
||||||
dbKey = getDatabaseKey() ?: ""
|
dbKey = ksDatabasePassword.get() ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dbKey
|
return dbKey
|
||||||
@@ -66,8 +73,39 @@ object DatabaseUtils {
|
|||||||
@Serializable
|
@Serializable
|
||||||
sealed class DBMigrationResult {
|
sealed class DBMigrationResult {
|
||||||
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
||||||
|
@Serializable @SerialName("invalidConfirmation") object InvalidConfirmation: DBMigrationResult()
|
||||||
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
||||||
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
|
@Serializable @SerialName("errorMigration") class ErrorMigration(val dbFile: String, val migrationError: MigrationError): DBMigrationResult()
|
||||||
|
@Serializable @SerialName("errorSQL") class ErrorSQL(val dbFile: String, val migrationSQLError: String): DBMigrationResult()
|
||||||
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
||||||
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class MigrationConfirmation(val value: String) {
|
||||||
|
YesUp("yesUp"),
|
||||||
|
YesUpDown ("yesUpDown"),
|
||||||
|
Error("error")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defaultMigrationConfirmation(appPrefs: AppPreferences): MigrationConfirmation =
|
||||||
|
if (appPrefs.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class MigrationError {
|
||||||
|
@Serializable @SerialName("upgrade") class Upgrade(val upMigrations: List<UpMigration>): MigrationError()
|
||||||
|
@Serializable @SerialName("downgrade") class Downgrade(val downMigrations: List<String>): MigrationError()
|
||||||
|
@Serializable @SerialName("migrationError") class Error(val mtrError: MTRError): MigrationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpMigration(
|
||||||
|
val upName: String,
|
||||||
|
// val withDown: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class MTRError {
|
||||||
|
@Serializable @SerialName("noDown") class NoDown(val dbMigrations: List<String>): MTRError()
|
||||||
|
@Serializable @SerialName("different") class Different(val appMigration: String, val dbMigration: String): MTRError()
|
||||||
|
}
|
||||||
|
|||||||
+115
-2
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -14,13 +15,19 @@ import androidx.compose.ui.focus.*
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.*
|
import androidx.compose.ui.text.input.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.database.PassphraseStrength
|
||||||
|
import chat.simplex.app.views.database.validKey
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -110,3 +117,109 @@ fun DefaultBasicTextField(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DefaultConfigurableTextField(
|
||||||
|
state: MutableState<TextFieldValue>,
|
||||||
|
placeholder: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showPasswordStrength: Boolean = false,
|
||||||
|
isValid: (String) -> Boolean,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
dependsOn: State<Any?>? = null,
|
||||||
|
) {
|
||||||
|
var valid by remember { mutableStateOf(validKey(state.value.text)) }
|
||||||
|
var showKey by remember { mutableStateOf(false) }
|
||||||
|
val icon = if (valid) {
|
||||||
|
if (showKey) painterResource(R.drawable.ic_visibility_off_filled) else painterResource(R.drawable.ic_visibility_filled)
|
||||||
|
} else painterResource(R.drawable.ic_error)
|
||||||
|
val iconColor = if (valid) {
|
||||||
|
if (showPasswordStrength && state.value.text.isNotEmpty()) PassphraseStrength.check(state.value.text).color else MaterialTheme.colors.secondary
|
||||||
|
} else Color.Red
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
|
||||||
|
autoCorrect = keyboardType != KeyboardType.Password,
|
||||||
|
keyboardType = keyboardType
|
||||||
|
)
|
||||||
|
val enabled = true
|
||||||
|
val colors = TextFieldDefaults.textFieldColors(
|
||||||
|
backgroundColor = Color.Unspecified,
|
||||||
|
textColor = MaterialTheme.colors.onBackground,
|
||||||
|
focusedIndicatorColor = Color.Unspecified,
|
||||||
|
unfocusedIndicatorColor = Color.Unspecified,
|
||||||
|
)
|
||||||
|
val color = MaterialTheme.colors.onBackground
|
||||||
|
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
BasicTextField(
|
||||||
|
value = state.value,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(colors.backgroundColor(enabled).value, shape)
|
||||||
|
.indicatorLine(enabled, false, interactionSource, colors)
|
||||||
|
.defaultMinSize(
|
||||||
|
minWidth = TextFieldDefaults.MinWidth,
|
||||||
|
minHeight = TextFieldDefaults.MinHeight
|
||||||
|
),
|
||||||
|
onValueChange = {
|
||||||
|
state.value = it
|
||||||
|
},
|
||||||
|
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||||
|
visualTransformation = if (showKey || keyboardType != KeyboardType.Password)
|
||||||
|
VisualTransformation.None
|
||||||
|
else
|
||||||
|
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboard?.hide()
|
||||||
|
keyboardActions.onDone?.invoke(this)
|
||||||
|
}),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle.Default.copy(
|
||||||
|
color = color,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
decorationBox = @Composable { innerTextField ->
|
||||||
|
TextFieldDefaults.TextFieldDecorationBox(
|
||||||
|
value = state.value.text,
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
isError = !valid,
|
||||||
|
trailingIcon = {
|
||||||
|
if (keyboardType == KeyboardType.Password || !valid) {
|
||||||
|
IconButton({ showKey = !showKey }) {
|
||||||
|
Icon(icon, null, tint = iconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||||
|
visualTransformation = VisualTransformation.None,
|
||||||
|
colors = colors
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { state.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(it.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
snapshotFlow { dependsOn?.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(state.value.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultDropdownMenu(
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||||
|
dropdownMenuItems: (@Composable () -> Unit)?
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu.value,
|
||||||
|
onDismissRequest = { showMenu.value = false },
|
||||||
|
Modifier
|
||||||
|
.widthIn(min = 250.dp)
|
||||||
|
.background(MaterialTheme.colors.surface)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
offset = offset,
|
||||||
|
) {
|
||||||
|
dropdownMenuItems?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
|
||||||
|
expanded: MutableState<Boolean>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dropdownMenuItems: (@Composable () -> Unit)?
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 200.dp)
|
||||||
|
.background(MaterialTheme.colors.surface)
|
||||||
|
.then(modifier),
|
||||||
|
expanded = expanded.value,
|
||||||
|
onDismissRequest = {
|
||||||
|
expanded.value = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
dropdownMenuItems?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.*
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultSwitch(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: ((Boolean) -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
colors: SwitchColors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = MaterialTheme.colors.secondary,
|
||||||
|
checkedTrackAlpha = 0.0f,
|
||||||
|
uncheckedTrackAlpha = 0.0f,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val color = if (checked) MaterialTheme.colors.primary.copy(alpha = 0.3f) else MaterialTheme.colors.secondary.copy(alpha = 0.3f)
|
||||||
|
val size = with(LocalDensity.current) { Size(46.dp.toPx(), 28.dp.toPx()) }
|
||||||
|
val offset = with(LocalDensity.current) { Offset(1.dp.toPx(), 10.dp.toPx()) }
|
||||||
|
val radius = with(LocalDensity.current) { 28.dp.toPx() }
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier.drawBehind { drawRoundRect(color, size = size, topLeft = offset, cornerRadius = CornerRadius(radius, radius)) },
|
||||||
|
colors = colors,
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,11 @@ import chat.simplex.app.R
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
@@ -34,7 +33,7 @@ fun DefaultTopAppBar(
|
|||||||
if (!showSearch) {
|
if (!showSearch) {
|
||||||
title?.invoke()
|
title?.invoke()
|
||||||
} else {
|
} else {
|
||||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = false, onSearchValueChanged)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||||
@@ -45,10 +44,10 @@ fun DefaultTopAppBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) {
|
||||||
IconButton(onButtonClicked) {
|
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
|
painterResource(R.drawable.ic_arrow_back_ios_new), stringResource(R.string.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
|||||||
fun ShareButton(onButtonClicked: () -> Unit) {
|
fun ShareButton(onButtonClicked: () -> Unit) {
|
||||||
IconButton(onButtonClicked) {
|
IconButton(onButtonClicked) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Share, stringResource(R.string.share_verb), tint = MaterialTheme.colors.primary
|
painterResource(R.drawable.ic_share), stringResource(R.string.share_verb), tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +65,7 @@ fun ShareButton(onButtonClicked: () -> Unit) {
|
|||||||
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||||
IconButton(onClick = onButtonClicked) {
|
IconButton(onClick = onButtonClicked) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Menu,
|
painterResource(R.drawable.ic_menu),
|
||||||
stringResource(R.string.icon_descr_settings),
|
stringResource(R.string.icon_descr_settings),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import kotlinx.serialization.encoding.Encoder
|
|||||||
|
|
||||||
sealed class SharedContent {
|
sealed class SharedContent {
|
||||||
data class Text(val text: String): SharedContent()
|
data class Text(val text: String): SharedContent()
|
||||||
data class Images(val text: String, val uris: List<Uri>): SharedContent()
|
data class Media(val text: String, val uris: List<Uri>): SharedContent()
|
||||||
data class File(val text: String, val uri: Uri): SharedContent()
|
data class File(val text: String, val uri: Uri): SharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +48,5 @@ object UriSerializer : KSerializer<Uri> {
|
|||||||
sealed class UploadContent {
|
sealed class UploadContent {
|
||||||
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
||||||
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
||||||
|
@Serializable data class Video(val uri: Uri, val duration: Int): UploadContent()
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-36
@@ -2,21 +2,19 @@ package chat.simplex.app.views.helpers
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.ExpandLess
|
|
||||||
import androidx.compose.material.icons.outlined.ExpandMore
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.views.usersettings.SettingsActionItemWithContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> ExposedDropDownSettingRow(
|
fun <T> ExposedDropDownSettingRow(
|
||||||
@@ -24,31 +22,17 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
values: List<Pair<T, String>>,
|
values: List<Pair<T, String>>,
|
||||||
selection: State<T>,
|
selection: State<T>,
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
icon: ImageVector? = null,
|
icon: Painter? = null,
|
||||||
iconTint: Color = HighOrLowlight,
|
iconTint: Color = MaterialTheme.colors.secondary,
|
||||||
enabled: State<Boolean> = mutableStateOf(true),
|
enabled: State<Boolean> = mutableStateOf(true),
|
||||||
onSelected: (T) -> Unit
|
onSelected: (T) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
|
||||||
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
val expanded = remember { mutableStateOf(false) }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
"",
|
|
||||||
Modifier.padding(end = 8.dp),
|
|
||||||
tint = iconTint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(title, Modifier.weight(1f), color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded.value,
|
||||||
onExpandedChange = {
|
onExpandedChange = {
|
||||||
expanded = !expanded && enabled.value
|
expanded.value = !expanded.value && enabled.value
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -56,39 +40,38 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
val maxWidth = with(LocalDensity.current){ 180.sp.toDp() }
|
val maxWidth = with(LocalDensity.current) { 180.sp.toDp() }
|
||||||
Text(
|
Text(
|
||||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||||
Modifier.widthIn(max = maxWidth),
|
Modifier.widthIn(max = maxWidth),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Icon(
|
Icon(
|
||||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
if (!expanded.value) painterResource(R.drawable.ic_expand_more) else painterResource(R.drawable.ic_expand_less),
|
||||||
generalGetString(R.string.icon_descr_more_button),
|
generalGetString(R.string.icon_descr_more_button),
|
||||||
tint = HighOrLowlight
|
tint = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExposedDropdownMenu(
|
DefaultExposedDropdownMenu(
|
||||||
modifier = Modifier.widthIn(min = 200.dp),
|
modifier = Modifier.widthIn(min = 200.dp),
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = {
|
|
||||||
expanded = false
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
values.forEach { selectionOption ->
|
values.forEach { selectionOption ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelected(selectionOption.first)
|
onSelected(selectionOption.first)
|
||||||
expanded = false
|
expanded.value = false
|
||||||
}
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
selectionOption.second + (if (label != null) " $label" else ""),
|
selectionOption.second + (if (label != null) " $label" else ""),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,10 +80,16 @@ suspend fun PointerInputScope.detectGesture(
|
|||||||
pressScope.release()
|
pressScope.release()
|
||||||
}
|
}
|
||||||
} catch (_: PointerEventTimeoutCancellationException) {
|
} catch (_: PointerEventTimeoutCancellationException) {
|
||||||
onLongPress?.invoke(down.position)
|
if (onLongPress != null) {
|
||||||
if (shouldConsume)
|
onLongPress(down.position)
|
||||||
consumeUntilUp()
|
if (shouldConsume)
|
||||||
pressScope.release()
|
consumeUntilUp()
|
||||||
|
pressScope.cancel()
|
||||||
|
} else {
|
||||||
|
if (shouldConsume)
|
||||||
|
consumeUntilUp()
|
||||||
|
pressScope.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.content.*
|
|||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.graphics.ImageDecoder.DecodeException
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -18,15 +17,13 @@ import androidx.activity.result.contract.ActivityResultContract
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Collections
|
|
||||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -114,7 +111,7 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
|
|||||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun createIntent(context: Context, input: Void?): Intent {
|
override fun createIntent(context: Context, input: Void?): Intent {
|
||||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
tmpFile = File.createTempFile("image", ".bmp", File(getAppFilesDirectory(SimplexApp.context)))
|
||||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||||
tmpFile?.deleteOnExit()
|
tmpFile?.deleteOnExit()
|
||||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||||
@@ -175,7 +172,18 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
rememberLauncherForActivityResult(contract = GetMultipleContentsAndMimeTypes(), cb)
|
||||||
|
|
||||||
|
class GetMultipleContentsAndMimeTypes: ActivityResultContracts.GetMultipleContents() {
|
||||||
|
override fun createIntent(context: Context, input: String): Intent {
|
||||||
|
val mimeTypes = input.split(";")
|
||||||
|
return super.createIntent(context, mimeTypes[0]).apply {
|
||||||
|
if (mimeTypes.isNotEmpty()) {
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||||
try {
|
try {
|
||||||
@@ -205,17 +213,10 @@ fun GetImageBottomSheet(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val processPickedImage = { uri: Uri? ->
|
val processPickedImage = { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
val bitmap = getBitmapFromUri(uri)
|
||||||
try {
|
if (bitmap != null) {
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
|
||||||
imageBitmap.value = uri
|
imageBitmap.value = uri
|
||||||
onImageChange(bitmap)
|
onImageChange(bitmap)
|
||||||
} catch (e: DecodeException) {
|
|
||||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title = generalGetString(R.string.image_decoding_exception_title),
|
|
||||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ fun GetImageBottomSheet(
|
|||||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
ActionButton(null, stringResource(R.string.use_camera_button), icon = painterResource(R.drawable.ic_photo_camera)) {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launchWithFallback()
|
cameraLauncher.launchWithFallback()
|
||||||
@@ -256,7 +257,7 @@ fun GetImageBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
ActionButton(null, stringResource(R.string.from_gallery_button), icon = painterResource(R.drawable.ic_image)) {
|
||||||
try {
|
try {
|
||||||
galleryLauncher.launch(0)
|
galleryLauncher.launch(0)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -20,9 +20,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.LinkPreview
|
import chat.simplex.app.model.LinkPreview
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
@@ -80,8 +78,9 @@ suspend fun getLinkPreview(url: String): LinkPreview? {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight),
|
Modifier.fillMaxWidth().padding(top = 8.dp).background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (linkPreview == null) {
|
if (linkPreview == null) {
|
||||||
@@ -91,7 +90,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
Modifier.size(16.dp),
|
Modifier.size(16.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -112,7 +111,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit) {
|
|||||||
}
|
}
|
||||||
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_link_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
@@ -135,7 +134,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview) {
|
|||||||
if (linkPreview.description != "") {
|
if (linkPreview.description != "") {
|
||||||
Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp)
|
Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp)
|
||||||
}
|
}
|
||||||
Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight)
|
Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-16
@@ -1,36 +1,71 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build.VERSION.SDK_INT
|
import android.os.Build.VERSION.SDK_INT
|
||||||
import android.widget.Toast
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricManager.Authenticators.*
|
import androidx.biometric.BiometricManager.Authenticators.*
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.SimplexApp
|
||||||
|
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||||
|
import chat.simplex.app.views.localauth.LocalAuthView
|
||||||
|
import chat.simplex.app.views.usersettings.LAMode
|
||||||
|
|
||||||
sealed class LAResult {
|
sealed class LAResult {
|
||||||
object Success: LAResult()
|
object Success: LAResult()
|
||||||
class Error(val errString: CharSequence): LAResult()
|
class Error(val errString: CharSequence): LAResult()
|
||||||
object Failed: LAResult()
|
class Failed(val errString: CharSequence? = null): LAResult()
|
||||||
object Unavailable: LAResult()
|
class Unavailable(val errString: CharSequence? = null): LAResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LocalAuthRequest (
|
||||||
|
val title: String?,
|
||||||
|
val reason: String,
|
||||||
|
val password: String,
|
||||||
|
val completed: (LAResult) -> Unit
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val sample = LocalAuthRequest(generalGetString(R.string.la_enter_app_passcode), generalGetString(R.string.la_authenticate), "") { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticate(
|
fun authenticate(
|
||||||
promptTitle: String,
|
promptTitle: String,
|
||||||
promptSubtitle: String,
|
promptSubtitle: String,
|
||||||
activity: FragmentActivity,
|
activity: FragmentActivity,
|
||||||
|
usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(),
|
||||||
completed: (LAResult) -> Unit
|
completed: (LAResult) -> Unit
|
||||||
) {
|
) {
|
||||||
when {
|
when (usingLAMode) {
|
||||||
SDK_INT in 28..29 ->
|
LAMode.SYSTEM -> when {
|
||||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
SDK_INT in 28..29 ->
|
||||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||||
SDK_INT > 29 ->
|
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
SDK_INT > 29 ->
|
||||||
else ->
|
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||||
completed(LAResult.Unavailable)
|
else -> completed(LAResult.Unavailable())
|
||||||
|
}
|
||||||
|
LAMode.PASSCODE -> {
|
||||||
|
val password = ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(R.string.la_no_app_password)))
|
||||||
|
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||||
|
BackHandler {
|
||||||
|
close()
|
||||||
|
completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||||
|
}
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password) {
|
||||||
|
close()
|
||||||
|
completed(it)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +101,7 @@ private fun authenticateWithBiometricManager(
|
|||||||
|
|
||||||
override fun onAuthenticationFailed() {
|
override fun onAuthenticationFailed() {
|
||||||
super.onAuthenticationFailed()
|
super.onAuthenticationFailed()
|
||||||
completed(LAResult.Failed)
|
completed(LAResult.Failed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -78,9 +113,7 @@ private fun authenticateWithBiometricManager(
|
|||||||
.build()
|
.build()
|
||||||
biometricPrompt.authenticate(promptInfo)
|
biometricPrompt.authenticate(promptInfo)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> completed(LAResult.Unavailable())
|
||||||
completed(LAResult.Unavailable)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +122,18 @@ fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
|||||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun laPasscodeNotSetAlert() = AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.lock_not_enabled),
|
||||||
|
generalGetString(R.string.you_can_turn_on_lock)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun laFailedAlert() {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.la_auth_failed),
|
||||||
|
text = generalGetString(R.string.la_could_not_be_verified)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.auth_unavailable),
|
generalGetString(R.string.auth_unavailable),
|
||||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.ui.theme.SettingsBackgroundLight
|
|
||||||
import chat.simplex.app.ui.theme.isInDarkTheme
|
import chat.simplex.app.ui.theme.isInDarkTheme
|
||||||
|
import chat.simplex.app.ui.theme.themedBackground
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -25,7 +25,7 @@ fun ModalView(
|
|||||||
) {
|
) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
Surface(Modifier.fillMaxSize()) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.background(background)) {
|
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
||||||
CloseSheetBar(close, endButtons)
|
CloseSheetBar(close, endButtons)
|
||||||
Box(modifier) { content() }
|
Box(modifier) { content() }
|
||||||
}
|
}
|
||||||
@@ -40,13 +40,13 @@ class ModalManager {
|
|||||||
|
|
||||||
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
|
fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) {
|
||||||
showCustomModal { close ->
|
showCustomModal { close ->
|
||||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, endButtons = endButtons, content = content)
|
ModalView(close, endButtons = endButtons, content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) {
|
fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) {
|
||||||
showCustomModal { close ->
|
showCustomModal { close ->
|
||||||
ModalView(close, if (!settings || isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight, content = { content(close) })
|
ModalView(close, content = { content(close) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
|
||||||
|
VideoPlayer.stopAll()
|
||||||
AudioPlayer.stop()
|
AudioPlayer.stop()
|
||||||
val rec: MediaRecorder
|
val rec: MediaRecorder
|
||||||
recorder = initRecorder().also { rec = it }
|
recorder = initRecorder().also { rec = it }
|
||||||
@@ -152,6 +153,7 @@ object AudioPlayer {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoPlayer.stopAll()
|
||||||
RecorderNative.stopRecording?.invoke()
|
RecorderNative.stopRecording?.invoke()
|
||||||
val current = currentlyPlaying.value
|
val current = currentlyPlaying.value
|
||||||
if (current == null || current.first != filePath) {
|
if (current == null || current.first != filePath) {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import androidx.compose.foundation.text.*
|
|||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||||
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
|
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
@@ -17,7 +15,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -29,15 +29,18 @@ import kotlinx.coroutines.delay
|
|||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) {
|
fun SearchTextField(modifier: Modifier, placeholder: String, alwaysVisible: Boolean, onValueChange: (String) -> Unit) {
|
||||||
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
if (!alwaysVisible) {
|
||||||
focusRequester.requestFocus()
|
LaunchedEffect(Unit) {
|
||||||
delay(200)
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
delay(200)
|
||||||
|
keyboard?.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -87,8 +90,15 @@ fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (Str
|
|||||||
Text(placeholder)
|
Text(placeholder)
|
||||||
},
|
},
|
||||||
trailingIcon = if (searchText.text.isNotEmpty()) {{
|
trailingIcon = if (searchText.text.isNotEmpty()) {{
|
||||||
IconButton({ searchText = TextFieldValue(""); onValueChange("") }) {
|
IconButton({
|
||||||
Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
if (alwaysVisible) {
|
||||||
|
keyboard?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
searchText = TextFieldValue("");
|
||||||
|
onValueChange("")
|
||||||
|
}) {
|
||||||
|
Icon(painterResource(R.drawable.ic_close), stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
|
||||||
}
|
}
|
||||||
}} else null,
|
}} else null,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
|||||||
@@ -3,42 +3,41 @@ import androidx.compose.foundation.combinedClickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Check
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ValueTitleDesc
|
import chat.simplex.app.views.helpers.ValueTitleDesc
|
||||||
import chat.simplex.app.views.helpers.ValueTitle
|
import chat.simplex.app.views.helpers.ValueTitle
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItemWithContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit)) {
|
fun SectionView(title: String? = null, padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit)) {
|
||||||
Column {
|
Column {
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Text(
|
Text(
|
||||||
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
|
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
||||||
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionView(
|
fun SectionView(
|
||||||
title: String,
|
title: String,
|
||||||
icon: ImageVector,
|
icon: Painter,
|
||||||
iconTint: Color = HighOrLowlight,
|
iconTint: Color = MaterialTheme.colors.secondary,
|
||||||
leadingIcon: Boolean = false,
|
leadingIcon: Boolean = false,
|
||||||
padding: PaddingValues = PaddingValues(),
|
padding: PaddingValues = PaddingValues(),
|
||||||
content: (@Composable ColumnScope.() -> Unit)
|
content: (@Composable ColumnScope.() -> Unit)
|
||||||
@@ -46,13 +45,11 @@ fun SectionView(
|
|||||||
Column {
|
Column {
|
||||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||||
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (leadingIcon) Icon(icon, null, Modifier.padding(end = 4.dp).size(iconSize), tint = iconTint)
|
if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
|
||||||
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, fontSize = 12.sp)
|
Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp)
|
||||||
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = 4.dp).size(iconSize), tint = iconTint)
|
if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint)
|
||||||
}
|
|
||||||
Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
|
|
||||||
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
|
||||||
}
|
}
|
||||||
|
Column(Modifier.padding(padding).fillMaxWidth()) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +67,7 @@ fun <T> SectionViewSelectable(
|
|||||||
SectionItemViewSpaceBetween({ onSelected(item.value) }) {
|
SectionItemViewSpaceBetween({ onSelected(item.value) }) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
if (currentValue.value == item.value) {
|
if (currentValue.value == item.value) {
|
||||||
Icon(Icons.Outlined.Check, item.title, tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_check), item.title, tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
@@ -85,13 +82,37 @@ fun SectionItemView(
|
|||||||
click: (() -> Unit)? = null,
|
click: (() -> Unit)? = null,
|
||||||
minHeight: Dp = 46.dp,
|
minHeight: Dp = 46.dp,
|
||||||
disabled: Boolean = false,
|
disabled: Boolean = false,
|
||||||
|
extraPadding: Boolean = false,
|
||||||
|
padding: PaddingValues = if (extraPadding)
|
||||||
|
PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING)
|
||||||
|
else
|
||||||
|
PaddingValues(horizontal = DEFAULT_PADDING),
|
||||||
content: (@Composable RowScope.() -> Unit)
|
content: (@Composable RowScope.() -> Unit)
|
||||||
) {
|
) {
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.sizeIn(minHeight = minHeight)
|
.sizeIn(minHeight = minHeight)
|
||||||
Row(
|
Row(
|
||||||
if (click == null || disabled) modifier.padding(horizontal = DEFAULT_PADDING) else modifier.clickable(onClick = click).padding(horizontal = DEFAULT_PADDING),
|
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionItemViewWithIcon(
|
||||||
|
click: (() -> Unit)? = null,
|
||||||
|
minHeight: Dp = 46.dp,
|
||||||
|
disabled: Boolean = false,
|
||||||
|
padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING),
|
||||||
|
content: (@Composable RowScope.() -> Unit)
|
||||||
|
) {
|
||||||
|
val modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sizeIn(minHeight = minHeight)
|
||||||
|
Row(
|
||||||
|
if (click == null || disabled) modifier.padding(padding) else modifier.clickable(onClick = click).padding(padding),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
@@ -125,24 +146,12 @@ fun <T> SectionItemWithValue(
|
|||||||
currentValue: State<T>,
|
currentValue: State<T>,
|
||||||
values: List<ValueTitle<T>>,
|
values: List<ValueTitle<T>>,
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
icon: ImageVector? = null,
|
icon: Painter? = null,
|
||||||
iconTint: Color = HighOrLowlight,
|
iconTint: Color = MaterialTheme.colors.secondary,
|
||||||
enabled: State<Boolean> = mutableStateOf(true),
|
enabled: State<Boolean> = mutableStateOf(true),
|
||||||
onSelected: () -> Unit
|
onSelected: () -> Unit
|
||||||
) {
|
) {
|
||||||
SectionItemView(click = if (enabled.value) onSelected else null) {
|
SettingsActionItemWithContent(icon = icon, text = title, iconColor = iconTint, click = if (enabled.value) onSelected else null, disabled = !enabled.value) {
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
Modifier.padding(end = 8.dp),
|
|
||||||
tint = iconTint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
|
||||||
|
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(start = 10.dp),
|
Modifier.padding(start = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -152,7 +161,7 @@ fun <T> SectionItemWithValue(
|
|||||||
values.first { it.value == currentValue.value }.title + (if (label != null) " $label" else ""),
|
values.first { it.value == currentValue.value }.title + (if (label != null) " $label" else ""),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +177,7 @@ fun SectionTextFooter(text: AnnotatedString) {
|
|||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
|
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
lineHeight = 18.sp,
|
lineHeight = 18.sp,
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
)
|
)
|
||||||
@@ -188,20 +197,41 @@ fun SectionDivider() {
|
|||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boolean = true) {
|
||||||
|
Divider(
|
||||||
|
Modifier.padding(
|
||||||
|
start = DEFAULT_PADDING_HALF,
|
||||||
|
top = if (maxTopPadding) 40.dp else 30.dp,
|
||||||
|
end = DEFAULT_PADDING_HALF,
|
||||||
|
bottom = if (maxBottomPadding) 40.dp else 30.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionSpacer() {
|
fun SectionSpacer() {
|
||||||
Spacer(Modifier.height(30.dp))
|
Spacer(Modifier.height(30.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoRow(title: String, value: String, icon: ImageVector? = null, iconTint: Color? = null) {
|
fun SectionBottomSpacer() {
|
||||||
|
Spacer(Modifier.height(DEFAULT_BOTTOM_PADDING))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextIconSpaced(extraPadding: Boolean = false) {
|
||||||
|
Spacer(Modifier.padding(horizontal = if (extraPadding) 17.dp else DEFAULT_PADDING_HALF))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null) {
|
||||||
SectionItemViewSpaceBetween {
|
SectionItemViewSpaceBetween {
|
||||||
Row {
|
Row {
|
||||||
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
|
||||||
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: HighOrLowlight)
|
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary)
|
||||||
Text(title)
|
Text(title)
|
||||||
}
|
}
|
||||||
Text(value, color = HighOrLowlight)
|
Text(value, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +247,7 @@ fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
|
|||||||
.widthIn(max = (configuration.screenWidthDp / 2).dp),
|
.widthIn(max = (configuration.screenWidthDp / 2).dp),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
@@ -81,6 +82,7 @@ fun imageMimeType(fileName: String): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */
|
||||||
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
fun saveImage(cxt: Context, ciFile: CIFile?) {
|
||||||
val filePath = getLoadedFilePath(cxt, ciFile)
|
val filePath = getLoadedFilePath(cxt, ciFile)
|
||||||
val fileName = ciFile?.fileName
|
val fileName = ciFile?.fileName
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
package chat.simplex.app.ui.theme
|
package chat.simplex.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.Share
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleButton(text: String, icon: ImageVector,
|
fun SimpleButton(text: String, icon: Painter,
|
||||||
color: Color = MaterialTheme.colors.primary,
|
color: Color = MaterialTheme.colors.primary,
|
||||||
click: () -> Unit) {
|
click: () -> Unit) {
|
||||||
SimpleButtonFrame(click) {
|
SimpleButtonFrame(click) {
|
||||||
@@ -28,26 +31,41 @@ fun SimpleButton(text: String, icon: ImageVector,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimpleButtonDecorated(text: String, icon: Painter,
|
||||||
|
color: Color = MaterialTheme.colors.primary,
|
||||||
|
textDecoration: TextDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight: FontWeight = FontWeight.Normal,
|
||||||
|
click: () -> Unit) {
|
||||||
|
SimpleButtonFrame(click) {
|
||||||
|
Icon(
|
||||||
|
icon, text, tint = color,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
Text(text, style = MaterialTheme.typography.caption, fontWeight = fontWeight, color = color, textDecoration = textDecoration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleButton(
|
fun SimpleButton(
|
||||||
text: String, icon: ImageVector,
|
text: String, icon: Painter,
|
||||||
color: Color = MaterialTheme.colors.primary,
|
color: Color = MaterialTheme.colors.primary,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
click: () -> Unit
|
click: () -> Unit
|
||||||
) {
|
) {
|
||||||
SimpleButtonFrame(click, disabled = disabled) {
|
SimpleButtonFrame(click, disabled = disabled) {
|
||||||
Icon(
|
Icon(
|
||||||
icon, text, tint = if (disabled) HighOrLowlight else color,
|
icon, text, tint = if (disabled) MaterialTheme.colors.secondary else color,
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
)
|
)
|
||||||
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) HighOrLowlight else color)
|
Text(text, style = MaterialTheme.typography.caption, color = if (disabled) MaterialTheme.colors.secondary else color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleButtonIconEnded(
|
fun SimpleButtonIconEnded(
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector,
|
icon: Painter,
|
||||||
color: Color = MaterialTheme.colors.primary,
|
color: Color = MaterialTheme.colors.primary,
|
||||||
click: () -> Unit
|
click: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -61,9 +79,9 @@ fun SimpleButtonIconEnded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) {
|
fun SimpleButtonFrame(click: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, content: @Composable () -> Unit) {
|
||||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
Box(Modifier.clip(RoundedCornerShape(20.dp))) {
|
||||||
val modifier = if (disabled) Modifier else Modifier.clickable { click() }
|
val modifier = if (disabled) modifier else modifier.clickable { click() }
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier.padding(8.dp)
|
modifier = modifier.padding(8.dp)
|
||||||
@@ -75,6 +93,6 @@ fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Co
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreviewCloseSheetBar() {
|
fun PreviewCloseSheetBar() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
|
SimpleButton(text = "Share", icon = painterResource(R.drawable.ic_share), click = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextEditor(
|
fun TextEditor(
|
||||||
@@ -41,11 +40,11 @@ fun TextEditor(
|
|||||||
autoCorrect = false
|
autoCorrect = false
|
||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
cursorBrush = SolidColor(HighOrLowlight),
|
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Surface(
|
Surface(
|
||||||
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
|
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
|
||||||
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
|
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant) else null
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.background(background),
|
Modifier.background(background),
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
//import android.app.LocaleManager
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.FileUtils
|
import android.os.*
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.SpannedString
|
import android.text.SpannedString
|
||||||
@@ -29,11 +35,15 @@ import androidx.compose.ui.unit.*
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.json
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.ThemeData
|
||||||
|
import chat.simplex.app.ui.theme.ThemeOverrides
|
||||||
|
import com.charleskorn.kaml.decodeFromStream
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -231,12 +241,18 @@ const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
|
|||||||
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
|
||||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
|
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
|
||||||
|
|
||||||
const val MAX_FILE_SIZE: Long = 8000000
|
const val MAX_FILE_SIZE_SMP: Long = 8000000
|
||||||
|
|
||||||
|
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824
|
||||||
|
|
||||||
fun getFilesDirectory(context: Context): String {
|
fun getFilesDirectory(context: Context): String {
|
||||||
return context.filesDir.toString()
|
return context.filesDir.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTempFilesDirectory(context: Context): String {
|
||||||
|
return "${getFilesDirectory(context)}/temp_files"
|
||||||
|
}
|
||||||
|
|
||||||
fun getAppFilesDirectory(context: Context): String {
|
fun getAppFilesDirectory(context: Context): String {
|
||||||
return "${getFilesDirectory(context)}/app_files"
|
return "${getFilesDirectory(context)}/app_files"
|
||||||
}
|
}
|
||||||
@@ -319,6 +335,14 @@ fun getFileName(context: Context, uri: Uri): String? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getAppFilePath(context: Context, uri: Uri): String? {
|
||||||
|
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
cursor.moveToFirst()
|
||||||
|
getAppFilePath(context, cursor.getString(nameIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getFileSize(context: Context, uri: Uri): Long? {
|
fun getFileSize(context: Context, uri: Uri): Long? {
|
||||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
@@ -327,9 +351,64 @@ fun getFileSize(context: Context, uri: Uri): Long? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getBitmapFromUri(uri: Uri, withAlertOnException: Boolean = true): Bitmap? {
|
||||||
|
return if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||||
|
try {
|
||||||
|
ImageDecoder.decodeBitmap(source)
|
||||||
|
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||||
|
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||||
|
if (withAlertOnException) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.image_decoding_exception_title),
|
||||||
|
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BitmapFactory.decodeFile(getAppFilePath(SimplexApp.context, uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable? {
|
||||||
|
return if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||||
|
try {
|
||||||
|
ImageDecoder.decodeDrawable(source)
|
||||||
|
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||||
|
if (withAlertOnException) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.image_decoding_exception_title),
|
||||||
|
text = generalGetString(R.string.image_decoding_exception_desc)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Drawable.createFromPath(getAppFilePath(SimplexApp.context, uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeData? {
|
||||||
|
SimplexApp.context.contentResolver.openInputStream(uri).use {
|
||||||
|
runCatching {
|
||||||
|
return yaml.decodeFromStream<ThemeData>(it!!)
|
||||||
|
}.onFailure {
|
||||||
|
if (withAlertOnException) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.import_theme_error),
|
||||||
|
text = generalGetString(R.string.import_theme_error_desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
fun saveImage(context: Context, uri: Uri): String? {
|
fun saveImage(context: Context, uri: Uri): String? {
|
||||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
val bitmap = getBitmapFromUri(uri) ?: return null
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
|
||||||
return saveImage(context, bitmap)
|
return saveImage(context, bitmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +479,7 @@ fun saveFileFromUri(context: Context, uri: Uri): String? {
|
|||||||
if (inputStream != null && fileToSave != null) {
|
if (inputStream != null && fileToSave != null) {
|
||||||
val destFileName = uniqueCombine(context, fileToSave)
|
val destFileName = uniqueCombine(context, fileToSave)
|
||||||
val destFile = File(getAppFilePath(context, destFileName))
|
val destFile = File(getAppFilePath(context, destFileName))
|
||||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||||
destFileName
|
destFileName
|
||||||
} else {
|
} else {
|
||||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||||
@@ -436,9 +515,9 @@ fun formatBytes(bytes: Long): String {
|
|||||||
return "0 bytes"
|
return "0 bytes"
|
||||||
}
|
}
|
||||||
val bytesDouble = bytes.toDouble()
|
val bytesDouble = bytes.toDouble()
|
||||||
val k = 1000.toDouble()
|
val k = 1024.toDouble()
|
||||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
val i = floor(log2(bytesDouble) / log2(k))
|
||||||
val size = bytesDouble / k.pow(i)
|
val size = bytesDouble / k.pow(i)
|
||||||
val unit = units[i.toInt()]
|
val unit = units[i.toInt()]
|
||||||
|
|
||||||
@@ -483,9 +562,32 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
|||||||
return fileCount to bytes
|
return fileCount to bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
|
||||||
|
return when (fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
|
||||||
|
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration {
|
||||||
|
val mmr = MediaMetadataRetriever()
|
||||||
|
mmr.setDataSource(SimplexApp.context, uri)
|
||||||
|
val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||||
|
val image = when {
|
||||||
|
timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||||
|
random -> mmr.frameAtTime
|
||||||
|
else -> mmr.getFrameAtTime(0)
|
||||||
|
}
|
||||||
|
mmr.release()
|
||||||
|
return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
fun Color.darker(factor: Float = 0.1f): Color =
|
fun Color.darker(factor: Float = 0.1f): Color =
|
||||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||||
|
|
||||||
|
fun Color.lighter(factor: Float = 0.1f): Color =
|
||||||
|
Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha)
|
||||||
|
|
||||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||||
|
|
||||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||||
@@ -501,3 +603,79 @@ inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
|||||||
save = { json.encodeToString(it) },
|
save = { json.encodeToString(it) },
|
||||||
restore = { json.decodeFromString(it) }
|
restore = { json.decodeFromString(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun saveAppLocale(pref: SharedPreference<String?>, activity: Activity, languageCode: String? = null) {
|
||||||
|
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||||
|
// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||||
|
// } else {
|
||||||
|
pref.set(languageCode)
|
||||||
|
if (languageCode == null) {
|
||||||
|
activity.applyLocale(SimplexApp.context.defaultLocale)
|
||||||
|
}
|
||||||
|
activity.recreate()
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Activity.applyAppLocale(pref: SharedPreference<String?>) {
|
||||||
|
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val lang = pref.get()
|
||||||
|
if (lang == null || lang == Locale.getDefault().language) return
|
||||||
|
applyLocale(Locale.forLanguageTag(lang))
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Activity.applyLocale(locale: Locale) {
|
||||||
|
Locale.setDefault(locale)
|
||||||
|
val appConf = Configuration(SimplexApp.context.resources.configuration).apply { setLocale(locale) }
|
||||||
|
val activityConf = Configuration(resources.configuration).apply { setLocale(locale) }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
SimplexApp.context.resources.updateConfiguration(appConf, resources.displayMetrics)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
resources.updateConfiguration(activityConf, resources.displayMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UriHandler.openUriCatching(uri: String) {
|
||||||
|
try {
|
||||||
|
openUri(uri)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
|
||||||
|
save = { it.width to it.height },
|
||||||
|
restore = { IntSize(it.first, it.second) }
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
always()
|
||||||
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
whenDispose()
|
||||||
|
if (orientation == activity.resources.configuration.orientation) {
|
||||||
|
whenGone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
always()
|
||||||
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
whenDispose()
|
||||||
|
if (orientation != activity.resources.configuration.orientation) {
|
||||||
|
whenRotate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import com.google.android.exoplayer2.*
|
||||||
|
import com.google.android.exoplayer2.C.*
|
||||||
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
|
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||||
|
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class VideoPlayer private constructor(
|
||||||
|
private val uri: Uri,
|
||||||
|
private val gallery: Boolean,
|
||||||
|
private val defaultPreview: Bitmap,
|
||||||
|
defaultDuration: Long,
|
||||||
|
soundEnabled: Boolean,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val players: MutableMap<Pair<Uri, Boolean>, VideoPlayer> = mutableMapOf()
|
||||||
|
private val previewsAndDurations: MutableMap<Uri, PreviewAndDuration> = mutableMapOf()
|
||||||
|
|
||||||
|
fun getOrCreate(
|
||||||
|
uri: Uri,
|
||||||
|
gallery: Boolean,
|
||||||
|
defaultPreview: Bitmap,
|
||||||
|
defaultDuration: Long,
|
||||||
|
soundEnabled: Boolean,
|
||||||
|
context: Context
|
||||||
|
): VideoPlayer =
|
||||||
|
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled, context) }
|
||||||
|
|
||||||
|
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||||
|
player(fileName, gallery)?.enableSound(enable) == true
|
||||||
|
|
||||||
|
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||||
|
fileName ?: return null
|
||||||
|
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(uri: Uri, gallery: Boolean, remove: Boolean) =
|
||||||
|
player(uri.path, gallery)?.release(remove)
|
||||||
|
|
||||||
|
fun stopAll() {
|
||||||
|
players.values.forEach { it.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseAll() {
|
||||||
|
players.values.forEach { it.release(false) }
|
||||||
|
players.clear()
|
||||||
|
previewsAndDurations.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long)
|
||||||
|
|
||||||
|
private val currentVolume: Float
|
||||||
|
val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||||
|
val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||||
|
val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||||
|
val progress: MutableState<Long> = mutableStateOf(0L)
|
||||||
|
val duration: MutableState<Long> = mutableStateOf(defaultDuration)
|
||||||
|
val preview: MutableState<Bitmap> = mutableStateOf(defaultPreview)
|
||||||
|
|
||||||
|
init {
|
||||||
|
setPreviewAndDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
val player = ExoPlayer.Builder(context,
|
||||||
|
DefaultRenderersFactory(context))
|
||||||
|
/*.setLoadControl(DefaultLoadControl.Builder()
|
||||||
|
.setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed
|
||||||
|
.createDefaultLoadControl())*/
|
||||||
|
.setSeekBackIncrementMs(10_000)
|
||||||
|
.setSeekForwardIncrementMs(10_000)
|
||||||
|
.build()
|
||||||
|
.apply {
|
||||||
|
// Repeat the same track endlessly
|
||||||
|
repeatMode = Player.REPEAT_MODE_ONE
|
||||||
|
currentVolume = volume
|
||||||
|
if (!soundEnabled) {
|
||||||
|
volume = 0f
|
||||||
|
}
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(USAGE_MEDIA)
|
||||||
|
.build(),
|
||||||
|
true // disallow to play multiple instances simultaneously
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||||
|
private var progressJob: Job? = null
|
||||||
|
|
||||||
|
enum class TrackState {
|
||||||
|
PLAYING, PAUSED, STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
|
||||||
|
val filepath = getAppFilePath(SimplexApp.context, uri)
|
||||||
|
if (filepath == null || !File(filepath).exists()) {
|
||||||
|
Log.e(TAG, "No such file: $uri")
|
||||||
|
brokenVideo.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soundEnabled.value) {
|
||||||
|
RecorderNative.stopRecording?.invoke()
|
||||||
|
}
|
||||||
|
AudioPlayer.stop()
|
||||||
|
stopAll()
|
||||||
|
if (listener.value == null) {
|
||||||
|
runCatching {
|
||||||
|
val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory())
|
||||||
|
val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri))
|
||||||
|
player.setMediaSource(source, seek ?: 0L)
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
brokenVideo.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (player.playbackState == PlaybackState.STATE_NONE || player.playbackState == PlaybackState.STATE_STOPPED) {
|
||||||
|
runCatching { player.prepare() }.onFailure {
|
||||||
|
// Can happen when video file is broken
|
||||||
|
Log.e(TAG, it.stackTraceToString())
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
|
||||||
|
brokenVideo.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seek != null) player.seekTo(seek)
|
||||||
|
player.play()
|
||||||
|
listener.value = onProgressUpdate
|
||||||
|
// Player can only be accessed in one specific thread
|
||||||
|
progressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
while (isActive && player.playbackState != Player.STATE_IDLE && player.playWhenReady) {
|
||||||
|
// Even when current position is equal to duration, the player has isPlaying == true for some time,
|
||||||
|
// so help to make the playback stopped in UI immediately
|
||||||
|
if (player.currentPosition == player.duration) {
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases
|
||||||
|
* the player can show position != duration even if they actually equal.
|
||||||
|
* Let's say to a listener that the position == duration in case of coroutine finished without cancel
|
||||||
|
* */
|
||||||
|
if (isActive) {
|
||||||
|
onProgressUpdate(player.duration, TrackState.PAUSED)
|
||||||
|
}
|
||||||
|
onProgressUpdate(null, TrackState.PAUSED)
|
||||||
|
}
|
||||||
|
player.addListener(object: Player.Listener{
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
super.onIsPlayingChanged(isPlaying)
|
||||||
|
// Produce non-ideal transition from stopped to playing state while showing preview image in ChatView
|
||||||
|
// videoPlaying.value = isPlaying
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
player.stop()
|
||||||
|
stopListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopListener() {
|
||||||
|
val afterCoroutineCancel: CompletionHandler = {
|
||||||
|
// Notify prev video listener about stop
|
||||||
|
listener.value?.invoke(null, TrackState.STOPPED)
|
||||||
|
}
|
||||||
|
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
|
||||||
|
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order)
|
||||||
|
* */
|
||||||
|
if (progressJob != null) {
|
||||||
|
progressJob?.invokeOnCompletion(afterCoroutineCancel)
|
||||||
|
} else {
|
||||||
|
afterCoroutineCancel(null)
|
||||||
|
}
|
||||||
|
progressJob?.cancel()
|
||||||
|
progressJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(resetOnEnd: Boolean) {
|
||||||
|
if (progress.value == duration.value) {
|
||||||
|
progress.value = 0
|
||||||
|
}
|
||||||
|
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||||
|
if (pro != null) {
|
||||||
|
progress.value = pro
|
||||||
|
}
|
||||||
|
if (pro == null || pro == duration.value) {
|
||||||
|
videoPlaying.value = false
|
||||||
|
if (pro == duration.value) {
|
||||||
|
progress.value = if (resetOnEnd) 0 else duration.value
|
||||||
|
}/* else if (state == TrackState.STOPPED) {
|
||||||
|
progress.value = 0 //
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableSound(enable: Boolean): Boolean {
|
||||||
|
if (soundEnabled.value == enable) return false
|
||||||
|
soundEnabled.value = enable
|
||||||
|
player.volume = if (enable) currentVolume else 0f
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(remove: Boolean) {
|
||||||
|
player.release()
|
||||||
|
if (remove) {
|
||||||
|
players.remove(uri to gallery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPreviewAndDuration() {
|
||||||
|
// It freezes main thread, doing it in IO thread
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
preview.value = previewAndDuration.preview ?: defaultPreview
|
||||||
|
duration.value = (previewAndDuration.duration ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package chat.simplex.app.views.localauth
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.ChatModel
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
|
||||||
|
val passcode = rememberSaveable { mutableStateOf("") }
|
||||||
|
PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode),
|
||||||
|
submit = {
|
||||||
|
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
|
||||||
|
authRequest.completed(r)
|
||||||
|
},
|
||||||
|
cancel = {
|
||||||
|
authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user