Merge branch 'master' into lp/markdown-compose

This commit is contained in:
Levitating Pineapple
2024-11-15 13:57:32 +02:00
154 changed files with 4338 additions and 2606 deletions
+1
View File
@@ -10,6 +10,7 @@ on:
- blog/**
- docs/**
- .github/workflows/web.yml
- PRIVACY.md
jobs:
build:
+1 -1
View File
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal build exe:simplex-chat
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
+106 -58
View File
@@ -3,27 +3,49 @@ layout: layouts/privacy.html
permalink: /privacy/index.html
---
# SimpleX Chat Privacy Policy and Conditions of Use
# SimpleX Chat Operators Privacy Policy and Conditions of Use
SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
## Summary
SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
[Introduction](#introduction) and [General principles](#general-principles) cover SimpleX Chat network design, the network operators, and the principles of privacy and security provided by SimpleX network.
[Privacy policy](#privacy-policy) covers:
- data stored only on your device - [your profiles](#user-profiles), delivered [messages and files](#messages-and-files). You can transfer this information to another device, and you are responsible for its preservation - if you delete the app it will be lost.
- [private message delivery](#private-message-delivery) that protects your IP address and connection graph from the destination servers.
- [undelivered messages and files](#storage-of-messages-and-files-on-the-servers) stored on the servers.
- [how users connect](#connections-with-other-users) without any user profile identifiers.
- [iOS push notifications](#ios-push-notifications) privacy limitations.
- [user support](#user-support), [SimpleX directory](#simplex-directory) and [any other data](#another-information-stored-on-the-servers) that may be stored on the servers.
- [preset server operators](#preset-server-operators) and the [information they may share](#information-preset-server-operators-may-share).
- [source code license](#source-code-license) and [updates to this document](#updates).
[Conditions of Use](#conditions-of-use-of-software-and-infrastructure) are the conditions you need to accept to use SimpleX Chat applications and the relay servers of preset operators. Their purpose is to protect the users and preset server operators.
*Please note*: this summary and any links in this document are provided for information only - they are not a part of the Privacy Policy and Conditions of Use.
## Introduction
SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Privacy Policy
SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
### General principles
SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
SimpleX software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server operators, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the servers operated by SimpleX Chat Ltd, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
@@ -33,35 +55,41 @@ The cryptographic review of SimpleX protocols design was done in July 2024 by Tr
#### User profiles
Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
Servers used by SimpleX Chat apps do not create, store or identify user chat profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
When you create the local profile, no records are created on any of the relay servers, and infrastructure operators, whether preset in the app or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
#### Messages and Files
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 4mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
#### Private message delivery
The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
#### Storage of messages and files on the servers
The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
#### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default.
SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
Preset and unmodified SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and operators to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
#### Connection links privacy
@@ -77,6 +105,8 @@ You can always safely replace the initial part of the link `https://simplex.chat
#### iOS Push Notifications
This section applies only to the notification servers operated by SimpleX Chat Ltd.
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
@@ -85,93 +115,111 @@ You can read more about the design of iOS push notifications [here](./blog/20220
#### Another information stored on the servers
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
#### SimpleX Directory
This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
#### User Support
If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
### Information we may share
### Preset Server Operators
SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
Preset server operators will not provide general access to their servers or the data on their servers to each other.
The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
Preset server operators will provide non-administrative access to control port of preset servers to SimpleX Chat Ltd, for the purposes of removing identified illegal content. This control port access only allows deleting known links and files, and access to aggregate statistics, but does NOT allow enumerating any information on the servers.
### Information Preset Server Operators May Share
The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
The cases when the preset server operators may share the data temporarily stored on the servers:
- To meet any applicable law, or enforceable governmental request or court order.
- To enforce applicable terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
At the time of updating this document, the preset server operators have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
### Source code license
As this software is fully open-source and provided under AGPLv3 license, all infrastructure owners and operators, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the servers.
In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
### Updates
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
Please also read our Conditions of Use of Software and Infrastructure below.
This Privacy Policy may be updated as needed so that it is current, accurate, and as clear as possible. When it is updated, you will have to review and accept the changed policy within 30 days of such changes to continue using preset relay servers. Even if you fail to accept the changed policy, your continued use of SimpleX Chat software applications and preset relay servers confirms your acceptance of the updated Privacy Policy.
If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
Please also read The Conditions of Use of Software and Infrastructure below.
If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
## Conditions of Use of Software and Infrastructure
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of SimpleX Chat software or using any of server infrastructure (collectively referred to as "Applications") operated by the Preset Server Operators, including SimpleX Chat Ltd, whether these servers are preset in the software or not.
**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
**Minimal age**. You must be at least 13 years old to use SimpleX Chat Applications. The minimum age to use SimpleX Applications without parental approval may be higher in your country.
**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
**Infrastructure**. Infrastructure of the preset server operators includes messaging and file relay servers. SimpleX Chat Ltd also provides iOS push notification servers for public use. This infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
**Operating Infrastructure**. For the purpose of using SimpleX Chat Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where preset server operators have or use facilities and service providers or partners. The information about geographic location and hosting providers of the preset messaging servers is available on server pages.
**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
**Software**. You agree to downloading and installing updates to SimpleX Chat Applications when they are available; they would only be automatic if you configure your devices in this way.
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using SimpleX Chat Applications, and any associated taxes.
**Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
**Legal usage**. You agree to use SimpleX Chat Applications only for legal purposes. You will not use (or assist others in using) the Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, other preset server operators, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While server operators cannot access content or identify messages or groups, in some cases the links to the illegal communications can be shared publicly on social media or websites. Preset server operators reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via their servers, whether they were reported by the users or discovered by the operators themselves.
**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
**Damage to SimpleX Chat Ltd and Preset Server Operators**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit SimpleX Chat Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, other preset server operators, their Infrastructure, or any other systems. For example, you must not 1) access preset operators' Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of preset operators' Infrastructure; 3) collect information about the users in any manner; or 4) sell, rent, or charge for preset operators' Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts.
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts. SimpleX Chat Ltd and other preset server operators are not responsible for any data loss.
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**No Access to Emergency Services**. SimpleX Chat Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Third-party services**. SimpleX Chat Applications may allow you to access, use, or interact with the websites of SimpleX Chat Ltd, preset server operators or other third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
**Your Rights**. You own the messages and the information you transmit through SimpleX Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use SimpleX Chat Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**SimpleX Chat Ltd Rights**. SimpleX Chat Ltd (and, where applicable, preset server operators) owns all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with the Applications. You may not use SimpleX Chat Ltd copyrights, trademarks, domains, logos, and other intellectual property rights unless you have SimpleX Chat Ltd written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Disclaimers**. YOU USE SIMPLEX APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. SIMPLEX CHAT LTD PROVIDES APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY THEM IS ACCURATE, COMPLETE, OR USEFUL, THAT THEIR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT THEIR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN THE USERS USE APPLICATIONS. SIMPLEX CHAT LTD AND OTHER PRESET OPERATORS ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF THEIR USERS OR OTHER THIRD PARTIES. YOU RELEASE SIMPLEX CHAT LTD, OTHER PRESET OPERATORS, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR SIMPLEX APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THE AGGREGATE LIABILITY OF THE SIMPLEX PARTIES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH THESE CONDITIONS, THE SIMPLEX PARTIES, OR THE APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN THE CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
**Availability**. The Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. SimpleX Chat Ltd may discontinue some or all of their Applications, including certain features and the support for certain devices and platforms, at any time. Preset server operators may discontinue providing the servers, at any time.
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
**Resolving disputes**. You agree to resolve any Claim you have with SimpleX Chat Ltd and/or preset server operators relating to or arising from these Conditions, them, or the Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern these Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd (or preset server operators) and you, without regard to conflict of law provisions.
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. The updated conditions have to be accepted within 30 days. Even if you fail to accept updated conditions, your continued use of SimpleX Chat Applications confirms your acceptance of the updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. These Conditions cover the entire agreement between you and SimpleX Chat Ltd, and any preset server operators where applicable, regarding SimpleX Chat Applications. If you do not agree with these Conditions, you should stop using the Applications.
**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
**Enforcing the conditions**. If SimpleX Chat Ltd or preset server operators fail to enforce any of these Conditions, that does not mean they waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from the Conditions and shall not affect the enforceability of the remaining provisions. The Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject SimpleX Chat Ltd to any regulations in another country. SimpleX Chat Ltd reserve the right to limit the access to the Applications in any country. Preset operators reserve the right to limit access to their servers in any country. If you have specific questions about these Conditions, please contact SimpleX Chat Ltd at chat@simplex.chat.
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
Updated October 14, 2024
Updated November 14, 2024
@@ -124,7 +124,7 @@ struct UserPicker: View {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
unreadBadge(u).offset(x: 4, y: -4)
UnreadBadge(userInfo: u).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
@@ -169,15 +169,21 @@ struct UserPicker: View {
}
}
}
private func unreadBadge(_ u: UserInfo) -> some View {
}
struct UnreadBadge: View {
var userInfo: UserInfo
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
return unreadCountText(u.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
unreadCountText(userInfo.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
@@ -21,6 +21,7 @@ struct UserProfilesView: View {
@State private var profileHidden = false
@State private var profileAction: UserProfileAction?
@State private var actionPassword = ""
@State private var navigateToProfileCreate = false
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
@@ -55,17 +56,6 @@ struct UserProfilesView: View {
}
var body: some View {
if authorized {
userProfilesView()
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
}
}
private func runAuth() { authorize(NSLocalizedString("Open user profiles", comment: "authentication reason"), $authorized) }
private func userProfilesView() -> some View {
List {
if profileHidden {
Button {
@@ -77,12 +67,14 @@ struct UserProfilesView: View {
Section {
let users = filteredUsers()
let v = ForEach(users) { u in
userView(u.user)
userView(u)
}
if #available(iOS 16, *) {
v.onDelete { indexSet in
if let i = indexSet.first {
confirmDeleteUser(users[i].user)
withAuth {
confirmDeleteUser(users[i].user)
}
}
}
} else {
@@ -90,12 +82,22 @@ struct UserProfilesView: View {
}
if trimmedSearchTextOrPassword == "" {
NavigationLink {
CreateProfile()
} label: {
NavigationLink(
destination: CreateProfile(),
isActive: $navigateToProfileCreate
) {
Label("Add profile", systemImage: "plus")
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 38)
.padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
.contentShape(Rectangle())
.onTapGesture {
withAuth {
self.navigateToProfileCreate = true
}
}
.padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
}
.frame(height: 38)
}
} footer: {
Text("Tap to activate profile.")
@@ -189,7 +191,25 @@ struct UserProfilesView: View {
private var visibleUsersCount: Int {
m.users.filter({ u in !u.user.hidden }).count
}
private func withAuth(_ action: @escaping () -> Void) {
if authorized {
action()
} else {
authenticate(
reason: NSLocalizedString("Change user profiles", comment: "authentication reason")
) { laResult in
switch laResult {
case .success, .unavailable:
authorized = true
AppSheetState.shared.scenePhaseActive = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: action)
case .failed: authorized = false
}
}
}
}
private func correctPassword(_ user: User, _ pwd: String) -> Bool {
if let ph = user.viewPwdHash {
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
@@ -213,8 +233,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("trash", color: theme.colors.secondary) {
Button("Delete chat profile", role: .destructive) {
profileAction = nil
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
withAuth {
profileAction = nil
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
}
}
.disabled(!actionEnabled(user))
}
@@ -231,8 +253,10 @@ struct UserProfilesView: View {
passwordField
settingsRow("lock.open", color: theme.colors.secondary) {
Button("Unhide chat profile") {
profileAction = nil
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
withAuth{
profileAction = nil
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
}
}
.disabled(!actionEnabled(user))
}
@@ -255,11 +279,13 @@ struct UserProfilesView: View {
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
Button(title, role: .destructive) {
if let user = userToDelete {
if passwordEntryRequired(user) {
profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
} else {
alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
withAuth {
if let user = userToDelete {
if passwordEntryRequired(user) {
profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues)
} else {
alert = .deleteUser(user: user, delSMPQueues: delSMPQueues)
}
}
}
}
@@ -301,7 +327,8 @@ struct UserProfilesView: View {
}
}
@ViewBuilder private func userView(_ user: User) -> some View {
@ViewBuilder private func userView(_ userInfo: UserInfo) -> some View {
let user = userInfo.user
let v = Button {
Task {
do {
@@ -319,12 +346,19 @@ struct UserProfilesView: View {
Spacer()
if user.activeUser {
Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground)
} else if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else {
Image(systemName: "checkmark").foregroundColor(.clear)
if userInfo.unreadCount > 0 {
UnreadBadge(userInfo: userInfo)
}
if user.hidden {
Image(systemName: "lock").foregroundColor(theme.colors.secondary)
} else if userInfo.unreadCount == 0 {
if !user.showNtfs {
Image(systemName: "speaker.slash").foregroundColor(theme.colors.secondary)
} else {
Image(systemName: "checkmark").foregroundColor(.clear)
}
}
}
}
}
@@ -332,30 +366,38 @@ struct UserProfilesView: View {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden {
Button("Unhide") {
if passwordEntryRequired(user) {
profileAction = .unhideUser(user: user)
} else {
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
withAuth {
if passwordEntryRequired(user) {
profileAction = .unhideUser(user: user)
} else {
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: trimmedSearchTextOrPassword) }
}
}
}
.tint(.green)
} else {
if visibleUsersCount > 1 {
Button("Hide") {
selectedUser = user
withAuth {
selectedUser = user
}
}
.tint(.gray)
}
Group {
if user.showNtfs {
Button("Mute") {
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
try await apiMuteUser(user.userId)
withAuth {
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
try await apiMuteUser(user.userId)
}
}
}
} else {
Button("Unmute") {
setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
withAuth {
setUserPrivacy(user) { try await apiUnmuteUser(user.userId) }
}
}
}
}
@@ -367,7 +409,9 @@ struct UserProfilesView: View {
} else {
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
confirmDeleteUser(user)
withAuth {
confirmDeleteUser(user)
}
}
}
}
+8 -8
View File
@@ -149,9 +149,9 @@
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */; };
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */; };
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
@@ -492,9 +492,9 @@
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a"; sourceTree = "<group>"; };
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; sourceTree = "<group>"; };
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a"; sourceTree = "<group>"; };
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; sourceTree = "<group>"; };
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
@@ -663,8 +663,8 @@
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a in Frameworks */,
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -815,8 +815,8 @@
643B3B422CCBEB080083A2CF /* libffi.a */,
643B3B442CCBEB080083A2CF /* libgmp.a */,
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-KwHIA7FZqPI5ZTCAoi00n9.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */,
5CA059C2279559F40002BEB4 /* Shared */,
5CDCAD462818589900503DA2 /* SimpleX NSE */,
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
@@ -4,8 +4,10 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.*
import android.view.View
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.ui.platform.ClipboardManager
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.model.NtfManager
@@ -13,7 +15,6 @@ import chat.simplex.app.model.NtfManager.getUserIdFromIntent
import chat.simplex.common.*
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
@@ -24,13 +25,21 @@ import kotlinx.coroutines.*
import java.lang.ref.WeakReference
class MainActivity: FragmentActivity() {
companion object {
const val OLD_ANDROID_UI_FLAGS = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
override fun onCreate(savedInstanceState: Bundle?) {
mainActivity = WeakReference(this)
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
applyAppLocale(ChatModel.controller.appPrefs.appLanguage)
// This flag makes status bar and navigation bar fully transparent. But on API level < 30 it breaks insets entirely
// https://issuetracker.google.com/issues/236862874
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
}
super.onCreate(savedInstanceState)
// testJson()
// When call ended and orientation changes, it re-process old intent, it's unneeded.
@@ -47,6 +56,7 @@ class MainActivity: FragmentActivity() {
WindowManager.LayoutParams.FLAG_SECURE
)
}
enableEdgeToEdge()
setContent {
AppScreen()
}
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.*
import android.view.View
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -16,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.MainActivity.Companion.OLD_ANDROID_UI_FLAGS
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
@@ -26,7 +28,6 @@ import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chatlist.statusBarColorAfterCall
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
@@ -274,79 +275,32 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode)
}
override fun androidSetDrawerStatusAndNavBarColor(
isLight: Boolean,
drawerShadingColor: Color,
toolbarOnTop: Boolean,
navBarColor: Color,
) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
// Blend status bar color to the animated color
val colors = CurrentColors.value.colors
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
var statusBar = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
var statusBarLight = isLight
// SimplexGreen while in call
if (window.statusBarColor == SimplexGreen.toArgb()) {
statusBarColorAfterCall.intValue = statusBar
statusBar = SimplexGreen.toArgb()
statusBarLight = false
}
window.statusBarColor = statusBar
val navBar = navBarColor.toArgb()
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
}
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
}
}
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
override fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean, themeBackgroundColor: Color) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val statusLight = isLightStatusBar && chatModel.activeCall.value == null
val navBarLight = isLightNavBar || windowOrientation() == WindowOrientation.LANDSCAPE
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
var statusBar = (if (hasTop && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
if (CurrentColors.value.base == DefaultTheme.SIMPLEX) {
backgroundColor.lighter(0.4f)
if (windowInsetController?.isAppearanceLightStatusBars != statusLight) {
windowInsetController?.isAppearanceLightStatusBars = statusLight
}
window.navigationBarColor = Color.Transparent.toArgb()
if (windowInsetController?.isAppearanceLightNavigationBars != navBarLight) {
windowInsetController?.isAppearanceLightNavigationBars = navBarLight
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
window.decorView.systemUiVisibility = if (statusLight && navBarLight) {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
} else if (statusLight) {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or OLD_ANDROID_UI_FLAGS
} else if (navBarLight) {
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
} else {
backgroundColor
OLD_ANDROID_UI_FLAGS
}
}).toArgb()
var statusBarLight = isLight
// SimplexGreen while in call
if (window.statusBarColor == SimplexGreen.toArgb()) {
statusBarColorAfterCall.intValue = statusBar
statusBar = SimplexGreen.toArgb()
statusBarLight = false
}
val navBar = (if (hasBottom && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
window.navigationBarColor = if (blackNavBar) Color.Black.toArgb() else themeBackgroundColor.toArgb()
} else {
backgroundColor
}).toArgb()
if (window.statusBarColor != statusBar) {
window.statusBarColor = statusBar
}
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
}
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
window.navigationBarColor = Color.Transparent.toArgb()
}
}
@@ -401,6 +355,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
return true
}
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
}
}
}
@@ -64,7 +64,6 @@ kotlin {
implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("com.google.accompanist:accompanist-insets:0.30.1")
// Video support
implementation("com.google.android.exoplayer:exoplayer:2.19.1")
@@ -3,25 +3,13 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import com.google.accompanist.insets.navigationBarsWithImePadding
import java.io.File
actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding()
@Composable
actual fun ProvideWindowInsets(
consumeWindowInsets: Boolean,
windowInsetsAnimationsEnabled: Boolean,
content: @Composable () -> Unit
) {
com.google.accompanist.insets.ProvideWindowInsets(content = content)
}
@Composable
actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean,
onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit,
onImage: (File) -> Unit,
onText: (String) -> Unit
): Modifier = this
@@ -6,8 +6,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.OnReceiveContentListener
import android.view.ViewGroup
import android.view.*
import android.view.inputmethod.*
import android.widget.EditText
import android.widget.TextView
@@ -26,6 +25,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doAfterTextChanged
@@ -94,8 +94,8 @@ actual fun PlatformTextField(
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
AndroidView(modifier = Modifier, factory = { context ->
val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: OnReceiveContentListener?
@@ -140,6 +140,13 @@ actual fun PlatformTextField(
Log.e(TAG, e.stackTraceToString())
}
}
editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
// shows keyboard when user had search field on ChatView focused before clicking on this text field
// it still produce weird animation of closing/opening keyboard but the solution is to replace this Android EditText with Compose BasicTextField
if (hasFocus) {
showKeyboard = true
}
}
editText.doOnTextChanged { text, _, _, _ ->
if (!composeState.value.inProgress) {
onMessageChange(text.toString())
@@ -148,8 +155,12 @@ actual fun PlatformTextField(
}
}
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
val workaround = WorkaroundFocusSearchLayout(context)
workaround.addView(editText)
workaround.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
workaround
}) {
val it = it.children.first() as EditText
it.setTextColor(textColor.toArgb())
it.setHintTextColor(hintColor.toArgb())
it.hint = placeholder
@@ -6,11 +6,11 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.text.BidiFormatter
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -50,7 +50,11 @@ actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.re
}
@Composable
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun windowWidth(): Dp {
val direction = LocalLayoutDirection.current
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
return LocalConfiguration.current.screenWidthDp.dp - cutout.calculateStartPadding(direction) - cutout.calculateEndPadding(direction)
}
@Composable
actual fun windowHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp
@@ -7,10 +7,12 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chatlist.NavigationBarBackground
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.flow.filter
import kotlin.math.absoluteValue
@@ -25,25 +27,74 @@ actual fun LazyColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal,
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
) {
val state = state ?: LocalAppBarHandler.current?.listState ?: rememberLazyListState()
val connection = LocalAppBarHandler.current?.connection
val handler = LocalAppBarHandler.current
require(handler != null) { "Using LazyColumnWithScrollBar and without AppBarHandler is an error. Use LazyColumnWithScrollBarNoAppBar instead" }
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(Unit) {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
if (reverseLayout) {
snapshotFlow { state.layoutInfo.visibleItemsInfo.lastOrNull()?.offset ?: 0 }
.collect { scrollPosition ->
connection.appBarOffset = if (state.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.layoutInfo.totalItemsCount - 1) {
state.layoutInfo.viewportEndOffset - scrollPosition.toFloat() - state.layoutInfo.afterContentPadding
} else {
// show always when last item is not visible
-1000f
}
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
} else {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection.appBarOffset
if ((offset + scrollPosition + state.layoutInfo.afterContentPadding).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
}
if (connection != null) {
LazyColumn(modifier.nestedScroll(connection), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
} else {
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
LazyColumn(
if (fillMaxSize) {
Modifier.fillMaxSize().copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
} else {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
},
state,
contentPadding,
reverseLayout,
verticalArrangement,
horizontalAlignment,
flingBehavior,
userScrollEnabled
) {
content()
}
}
@Composable
actual fun LazyColumnWithScrollBarNoAppBar(
modifier: Modifier,
state: LazyListState?,
contentPadding: PaddingValues,
reverseLayout: Boolean,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
content: LazyListScope.() -> Unit
) {
val state = state ?: rememberLazyListState()
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) {
content()
}
}
@@ -54,32 +105,80 @@ actual fun ColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal,
state: ScrollState?,
maxIntrinsicSize: Boolean,
fillMaxSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
val state = state ?: LocalAppBarHandler.current?.scrollState ?: rememberScrollState()
val connection = LocalAppBarHandler.current?.connection
val handler = LocalAppBarHandler.current
require(handler != null) { "Using ColumnWithScrollBar and without AppBarHandler is an error. Use ColumnWithScrollBarNoAppBar instead" }
val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier).imePadding() else modifier.imePadding()
val state = state ?: handler.scrollState
val connection = handler.connection
LaunchedEffect(Unit) {
snapshotFlow { state.value }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
val offset = connection.appBarOffset
if ((offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
if (connection != null) {
Column(
if (maxIntrinsicSize) {
modifier.nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.nestedScroll(connection).verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
} else {
Column(if (maxIntrinsicSize) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxHeight()) {
Column(
if (maxIntrinsicSize) {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
} else {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state)
}, verticalArrangement, horizontalAlignment
) {
if (oneHandUI.value) {
Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp).windowInsetsTopHeight(WindowInsets.statusBars))
content()
Spacer(Modifier.navigationBarsPadding().padding(bottom = AppBarHeight * fontSizeSqrtMultiplier))
} else {
Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier))
content()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(false, false)
}
}
}
@Composable
actual fun ColumnWithScrollBarNoAppBar(
modifier: Modifier,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
state: ScrollState?,
maxIntrinsicSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
val modifier = modifier.imePadding()
val state = state ?: rememberScrollState()
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxHeight()) {
Column(
if (maxIntrinsicSize) {
modifier.verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
}, verticalArrangement, horizontalAlignment
) {
if (oneHandUI.value) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
content()
} else {
content()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(false, false)
}
}
}
@@ -3,17 +3,18 @@ package chat.simplex.common.platform
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.os.*
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen
import chat.simplex.common.model.clear
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR
@@ -43,28 +44,13 @@ actual fun LocalMultiplatformView(): Any? = LocalView.current
@Composable
actual fun getKeyboardState(): State<KeyboardState> {
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
KeyboardState.Opened
} else {
KeyboardState.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
val density = LocalDensity.current
val ime = WindowInsets.ime
return remember {
derivedStateOf {
if (ime.getBottom(density) == 0) KeyboardState.Closed else KeyboardState.Opened
}
}
return keyboardState
}
actual fun hideKeyboard(view: Any?, clearFocus: Boolean) {
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -48,6 +47,7 @@ import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import com.google.accompanist.permissions.*
import dev.icerock.moko.resources.StringResource
@@ -329,11 +329,14 @@ private fun ActiveCallOverlayLayout(
flipCamera: () -> Unit
) {
Column {
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
if (call.hasVideo) {
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
}
CallAppBar(
title = {
if (call.hasVideo) {
Text(call.contact.chatViewName, Modifier.offset(x = (-4).dp).padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
},
onBack = { chatModel.activeCallViewIsCollapsed.value = true }
)
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
@Composable
fun SelectSoundDevice(size: Dp) {
@@ -411,6 +414,7 @@ private fun ControlButton(icon: Painter, iconText: StringResource, enabled: Bool
@Composable
private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, content: @Composable () -> Unit) {
val ripple = remember { ripple(bounded = false, radius = size / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -419,7 +423,7 @@ private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, backg
onClick = action,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = size / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled
),
contentAlignment = Alignment.Center
@@ -590,8 +594,9 @@ fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Uni
}
}
} else {
ModalView(background = Color.Black, showClose = false, close = {}) {
ColumnWithScrollBar(Modifier.fillMaxSize()) {
ModalView(background = Color.Black, showAppBar = false, close = {}) {
Column {
Spacer(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
AppBarTitle(stringResource(MR.strings.permissions_required))
Spacer(Modifier.weight(1f))
val onClick = {
@@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.ModalManager
@@ -39,14 +38,6 @@ actual fun SimpleAndAnimatedImageView(
if (getLoadedFilePath(file) != null) {
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
if (smallView) {
DisposableEffect(Unit) {
onDispose {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
}
}
}
}
}
@@ -1,32 +1,26 @@
package chat.simplex.common.views.chatlist
import android.app.Activity
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
import chat.simplex.common.model.durationText
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
@@ -38,11 +32,12 @@ private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM
@Composable
actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT + statusBar)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
GreenLine(call)
val ripple = remember { ripple(bounded = true, 3000.dp) }
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT + statusBar).clickable(onClick = onClick, indication = ripple, interactionSource = source)) {
GreenLine(statusBar, call)
}
Box(
Modifier
@@ -50,7 +45,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.clickable(onClick = onClick, indication = ripple, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
@@ -63,16 +58,13 @@ actual fun ActiveCallInteractiveArea(call: Call) {
}
}
// Temporary solution for storing a color that needs to be applied after call ends
var statusBarColorAfterCall = mutableIntStateOf(CurrentColors.value.colors.background.toArgb())
@Composable
private fun GreenLine(call: Call) {
private fun GreenLine(statusBarHeight: Dp, call: Call) {
Row(
Modifier
.fillMaxSize()
.background(SimplexGreen)
.padding(top = -CALL_TOP_OFFSET)
.padding(top = -CALL_TOP_OFFSET + statusBarHeight)
.padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -81,12 +73,10 @@ private fun GreenLine(call: Call) {
Spacer(Modifier.weight(1f))
CallDuration(call)
}
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
statusBarColorAfterCall.intValue = window.statusBarColor
window.statusBarColor = SimplexGreen.toArgb()
platform.androidSetStatusAndNavigationBarAppearance(false, CurrentColors.value.colors.isLight)
onDispose {
window.statusBarColor = statusBarColorAfterCall.intValue
platform.androidSetStatusAndNavigationBarAppearance(CurrentColors.value.colors.isLight, CurrentColors.value.colors.isLight)
}
}
}
@@ -19,13 +19,11 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,6 +34,7 @@ private val USER_PICKER_ROW_PADDING = 16.dp
@Composable
actual fun UserPickerUsersSection(
users: List<UserInfo>,
iconColor: Color,
stopped: Boolean,
onUserClicked: (user: User) -> Unit,
) {
@@ -140,87 +139,73 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<
} else {
Modifier
}
Box(
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
if (pickerState.value.isVisible()) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = colors.background.mixWith(colors.onBackground, 1 - userPickerAlpha())
)
} else if (ModalManager.start.modalCount.value == 0) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
colors.background
})
)
}
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = calculatePosition(dismissState)
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
}
}
}
}
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier)) {
content()
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier).navigationBarsPadding()) {
content()
}
}
}
NavigationBarBackground(
modifier = Modifier.graphicsLayer { alpha = if (calculatePosition(dismissState) > 0.1f) 1f else 0f },
color = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha())
)
}
}
private fun calculatePosition(dismissState: DismissState): Float = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
private fun Modifier.draggableBottomDrawerModifier(
state: DismissState,
swipeDistance: Float,
@@ -171,6 +171,8 @@ actual fun GetImageBottomSheet(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.imePadding()
.navigationBarsPadding()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hideBottomSheet()
}
@@ -0,0 +1,41 @@
package chat.simplex.common.views.helpers
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
/**
* A workaround for the ANR issue on Compose 1.7.x.
* https://issuetracker.google.com/issues/369354336
* Code from:
* https://issuetracker.google.com/issues/369354336#comment8
*/
class WorkaroundFocusSearchLayout : FrameLayout {
constructor(
context: Context,
) : super(context)
constructor(
context: Context,
attrs: AttributeSet?,
) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
) : super(context, attrs, defStyleAttr)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun focusSearch(focused: View?, direction: Int): View? {
return null
}
}
@@ -2,6 +2,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionView
import android.app.Activity
import android.content.ComponentName
@@ -31,6 +32,7 @@ import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.saveAppLocale
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.painterResource
@@ -75,9 +77,7 @@ fun AppearanceScope.AppearanceLayout(
systemDarkTheme: SharedPreference<String?>,
changeIcon: (AppIcon) -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_interface), contentPadding = PaddingValues()) {
val context = LocalContext.current
@@ -106,15 +106,15 @@ fun AppearanceScope.AppearanceLayout(
}
// }
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
}
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI)
}
SectionDividerSpaced()
ThemesSection(systemDarkTheme)
SectionDividerSpaced()
AppToolbarsSection()
SectionDividerSpaced()
MessageShapeSection()
@@ -13,14 +13,13 @@ import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun SettingsSectionApp(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
) {
SectionView(stringResource(MR.strings.settings_section_title_app)) {
SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp)
SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) })
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) })
AppVersionItem(showVersion)
}
}
@@ -11,10 +11,13 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.*
@@ -39,14 +42,39 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.absoluteValue
@Composable
fun AppScreen() {
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
SimpleXTheme {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
MainScreen()
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
// This padding applies to landscape view only taking care of navigation bar and holes in screen in status bar area
// (because nav bar and holes located on vertical sides of screen in landscape view)
val direction = LocalLayoutDirection.current
val safePadding = WindowInsets.safeDrawing.asPaddingValues()
val cutout = WindowInsets.displayCutout.asPaddingValues()
val cutoutStart = cutout.calculateStartPadding(direction)
val cutoutEnd = cutout.calculateEndPadding(direction)
val cutoutMax = maxOf(cutoutStart, cutoutEnd)
val paddingStartUntouched = safePadding.calculateStartPadding(direction)
val paddingStart = paddingStartUntouched - cutoutStart
val paddingEndUntouched = safePadding.calculateEndPadding(direction)
val paddingEnd = paddingEndUntouched - cutoutEnd
// Such a strange layout is needed because the main content should be covered by solid color in order to hide overflow
// of some elements that may have negative offset (so, can't use Row {}).
// To check: go to developer settings of Android, choose Display cutout -> Punch hole, and rotate the phone to landscape, open any chat
Box {
val fullscreenGallery = remember { chatModel.fullscreenGalleryVisible }
Box(Modifier.padding(start = paddingStart + cutoutMax, end = paddingEnd + cutoutMax).consumeWindowInsets(PaddingValues(start = paddingStartUntouched, end = paddingEndUntouched))) {
Box(Modifier.drawBehind {
if (fullscreenGallery.value) {
drawRect(Color.Black, topLeft = Offset(-(paddingStart + cutoutMax).toPx(), 0f), Size(size.width + (paddingStart + cutoutMax).toPx() + (paddingEnd + cutoutMax).toPx(), size.height))
}
}) {
MainScreen()
}
}
}
}
}
@@ -138,7 +166,9 @@ fun MainScreen() {
}
SetupClipboardListener()
if (appPlatform.isAndroid) {
AndroidScreen(userPickerState)
AndroidWrapInCallLayout {
AndroidScreen(userPickerState)
}
} else {
DesktopScreen(userPickerState)
}
@@ -170,7 +200,9 @@ fun MainScreen() {
}
}
if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView()
AndroidWrapInCallLayout {
ModalManager.fullscreen.showInView()
}
SwitchingUsersView()
}
@@ -237,19 +269,39 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
fun AndroidWrapInCallLayout(content: @Composable () -> Unit) {
val call = remember { chatModel.activeCall}.value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
Box {
Box(Modifier.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)) {
content()
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call)
}
}
}
@Composable
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
val currentChatId = remember { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
val direction = LocalLayoutDirection.current
val hasCutout = cutout.calculateStartPadding(direction) + cutout.calculateEndPadding(direction) > 0.dp
Box(
Modifier
// clipping only for devices with cutout currently visible on sides. It prevents showing chat list with open chat view
// In order cases it's not needed to use clip
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
.graphicsLayer {
translationX = -offset.value.dp.toPx()
// minOf thing is needed for devices with holes in screen while the user on ChatView rotates his phone from portrait to landscape
// because in this case (at least in emulator) maxWidth changes in two steps: big first, smaller on next frame.
// But offset is remembered already, so this is a better way than dropping a value of offset
translationX = -minOf(offset.value.dp, maxWidth).toPx()
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
StartPartOfScreen(userPickerState)
}
@@ -271,51 +323,40 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it == null) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
onComposed(null)
}
if (it == null) onComposed(null)
currentChatId.value = it
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { ModalManager.center.modalCount.value > 0 }
.filter { chatModel.chatId.value == null }
.collect { modalBackground ->
if (chatModel.newChatSheetVisible.value) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, appPrefs.oneHandUI.get())
} else if (modalBackground) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
} else {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
}
}
Box(Modifier
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
.graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() }
) Box2@{
currentChatId.value?.let {
ChatView(currentChatId, onComposed)
}
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call)
}
}
}
@Composable
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
SetDeliveryReceiptsView(chatModel)
}
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
if (chatModel.sharedContent.value == null) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
}
} else {
// LALAL initial load of view doesn't show blur. Focusing text field shows it
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(keyboardCoversBar = false)) {
ShareListView(chatModel, stopped)
}
}
}
}
@@ -90,6 +90,9 @@ object ChatModel {
// Needed to check for bottom nav bar and to apply or not navigation bar color on Android
val newChatSheetVisible = mutableStateOf(false)
// Needed to apply black color to left/right cutout area on Android
val fullscreenGalleryVisible = mutableStateOf(false)
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
@@ -118,6 +118,9 @@ class AppPreferences {
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true)
val privacyMediaBlurRadius = mkIntPreference(SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS, 0)
// Blur broken on Android 12, see https://github.com/chrisbanes/haze/issues/77. And not available before 12
val deviceSupportsBlur = appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 32
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -223,6 +226,8 @@ class AppPreferences {
val chatItemTail = mkBoolPreference(SHARED_PREFS_CHAT_ITEM_TAIL, true)
val fontScale = mkFloatPreference(SHARED_PREFS_FONT_SCALE, 1f)
val densityScale = mkFloatPreference(SHARED_PREFS_DENSITY_SCALE, 1f)
val inAppBarsDefaultAlpha = if (deviceSupportsBlur) 0.875f else 0.975f
val inAppBarsAlpha = mkFloatPreference(SHARED_PREFS_IN_APP_BARS_ALPHA, inAppBarsDefaultAlpha)
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
@@ -244,7 +249,7 @@ class AppPreferences {
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, appPlatform.isAndroid)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
laNoticeShown to false,
@@ -362,6 +367,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays"
private const val SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS = "PrivacyMediaBlurRadius"
private const val SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS = "AppearanceBarsBlurRadius"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -428,6 +434,7 @@ class AppPreferences {
private const val SHARED_PREFS_CHAT_ITEM_TAIL = "ChatItemTail"
private const val SHARED_PREFS_FONT_SCALE = "FontScale"
private const val SHARED_PREFS_DENSITY_SCALE = "DensityScale"
private const val SHARED_PREFS_IN_APP_BARS_ALPHA = "InAppBarsAlpha"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
@@ -14,20 +14,11 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
expect fun Modifier.navigationBarsWithImePadding(): Modifier
@Composable
expect fun ProvideWindowInsets(
consumeWindowInsets: Boolean = true,
windowInsetsAnimationsEnabled: Boolean = true,
content: @Composable () -> Unit
)
@Composable
expect fun Modifier.desktopOnExternalDrag(
enabled: Boolean = true,
onFiles: (List<File>) -> Unit = {},
onImage: (Painter) -> Unit = {},
onImage: (File) -> Unit = {},
onText: (String) -> Unit = {}
): Modifier
@@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.ui.theme.CurrentColors
import kotlinx.coroutines.Job
interface PlatformInterface {
@@ -20,12 +21,12 @@ interface PlatformInterface {
fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean = false, themeBackgroundColor: Color = CurrentColors.value.colors.background) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
fun androidRestartNetworkObserver() {}
val androidApiLevel: Int? get() = null
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
@Composable fun desktopShowAppUpdateNotice() {}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@@ -21,11 +22,44 @@ expect fun LazyColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
fillMaxSize: Boolean = true,
content: LazyListScope.() -> Unit
)
@Composable
expect fun LazyColumnWithScrollBarNoAppBar(
modifier: Modifier = Modifier,
state: LazyListState? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
content: LazyListScope.() -> Unit
)
@Composable
expect fun ColumnWithScrollBar(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
state: ScrollState? = null,
// set true when you want to show something in the center with respected .fillMaxSize()
maxIntrinsicSize: Boolean = false,
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
fillMaxSize: Boolean = true,
content: @Composable ColumnScope.() -> Unit
)
@Composable
expect fun ColumnWithScrollBarNoAppBar(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
@@ -1,14 +1,14 @@
package chat.simplex.common.ui.theme
import androidx.compose.foundation.background
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
@@ -587,21 +587,27 @@ data class ThemeModeOverride (
}
}
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)
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState<IntSize>?, bgLayer: GraphicsLayer?/*, shape: Shape = RectangleShape*/): Modifier {
return drawBehind {
copyBackgroundToAppBar(bgLayerSize, bgLayer) {
if (baseTheme == DefaultTheme.SIMPLEX) {
drawRect(brush = themedBackgroundBrush())
} else {
drawRect(CurrentColors.value.colors.background)
}
}
}
}
fun themedBackgroundBrush(): 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)
)
val DEFAULT_PADDING = 20.dp
val DEFAULT_SPACE_AFTER_ICON = 4.dp
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
@@ -1,7 +1,6 @@
package chat.simplex.common.ui.theme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
@@ -107,7 +106,7 @@ object ThemeManager {
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !ChatController.appPrefs.oneHandUI.get(), ChatController.appPrefs.oneHandUI.get())
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
}
fun changeDarkTheme(theme: String) {
@@ -125,10 +124,6 @@ object ThemeManager {
themeIds[nonSystemThemeName] = prevValue.themeId
appPrefs.currentThemeIds.set(themeIds)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
if (name == ThemeColor.BACKGROUND) {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
}
}
fun applyThemeColor(name: ThemeColor, color: Color? = null, pref: MutableState<ThemeModeOverride>) {
@@ -7,40 +7,34 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import kotlinx.coroutines.flow.collect
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
import chat.simplex.common.views.chatlist.NavigationBarBackground
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
fun TerminalView(floating: Boolean = false, close: () -> Unit) {
fun TerminalView(floating: Boolean = false) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
val close = {
close()
if (appPlatform.isDesktop) {
ModalManager.center.closeModals()
}
}
BackHandler(onBack = {
close()
})
TerminalLayout(
composeState,
floating,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
}
@@ -69,7 +63,6 @@ fun TerminalLayout(
composeState: MutableState<ComposeState>,
floating: Boolean,
sendCommand: () -> Unit,
close: () -> Unit
) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
@@ -77,65 +70,63 @@ fun TerminalLayout(
fun onMessageChange(s: String) {
composeState.value = composeState.value.copy(message = s)
}
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = {
Column {
Divider()
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
placeholder = "",
sendMessage = { sendCommand() },
sendLiveMessage = null,
updateLiveMessage = null,
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
)
}
}
},
contentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxSize()) {
val composeViewHeight = remember { mutableStateOf(0.dp) }
AdaptingBottomPaddingLayout(Modifier, CONSOLE_COMPOSE_LAYOUT_ID, composeViewHeight) {
TerminalLog(floating, composeViewHeight)
Column(
Modifier
.layoutId(CONSOLE_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp))
.imePadding()
.padding(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp)
.background(MaterialTheme.colors.background)
) {
TerminalLog(floating)
Divider()
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
placeholder = "",
sendMessage = { sendCommand() },
sendLiveMessage = null,
updateLiveMessage = null,
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
)
}
}
}
if (!oneHandUI.value) {
NavigationBarBackground(true, oneHandUI.value)
}
}
}
@Composable
fun TerminalLog(floating: Boolean) {
fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
val reversedTerminalItems by remember {
derivedStateOf { chatModel.terminalItems.value.asReversed() }
}
val clipboard = LocalClipboardManager.current
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
LaunchedEffect(Unit) {
var autoScrollToBottom = true
var autoScrollToBottom = listState.firstVisibleItemIndex <= 1
launch {
snapshotFlow { listState.layoutInfo.totalItemsCount }
.filter { autoScrollToBottom }
@@ -150,12 +141,21 @@ fun TerminalLog(floating: Boolean) {
launch {
snapshotFlow { listState.firstVisibleItemIndex }
.collect {
autoScrollToBottom = listState.firstVisibleItemIndex == 0
autoScrollToBottom = it == 0
}
}
}
LazyColumnWithScrollBar(reverseLayout = true, state = listState) {
LazyColumnWithScrollBar (
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
state = listState,
additionalBarOffset = composeViewHeight
) {
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
val clipboard = LocalClipboardManager.current
val rhId = item.remoteHostId
val rhIdStr = if (rhId == null) "" else "$rhId "
Text(
@@ -172,13 +172,15 @@ fun TerminalLog(floating: Boolean) {
ModalManager.start
}
modalPlace.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
val details = item.details
.let {
if (it.length < 100_000) it
else it.substring(0, 100_000)
}
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
ColumnWithScrollBar {
SelectionContainer {
val details = item.details
.let {
if (it.length < 100_000) it
else it.substring(0, 100_000)
}
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}
}
}
}.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -208,8 +210,7 @@ fun PreviewTerminalLayout() {
TerminalLayout(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
floating = false,
close = {}
floating = false
)
}
}
@@ -40,8 +40,6 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -50,11 +48,9 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
ColumnWithScrollBar(
modifier = Modifier.fillMaxSize()
) {
ColumnWithScrollBar {
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
AppBarTitle(stringResource(MR.strings.create_profile), withPadding = false, bottomPadding = DEFAULT_PADDING)
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(MR.strings.display_name),
@@ -102,7 +98,6 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
}
}
}
}
}
@Composable
@@ -111,59 +106,42 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Column(
modifier = Modifier
.fillMaxSize()
.themedBackground(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close = {
if (chatModel.users.none { !it.user.hidden }) {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
close()
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({
if (chatModel.users.none { !it.user.hidden }) {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
close()
}
}) {
ColumnWithScrollBar {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
}
})
BackHandler(onBack = {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
})
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.create_profile_button,
onboarding = null,
enabled = canCreateProfile(displayName.value),
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
ColumnWithScrollBar(
modifier = Modifier.fillMaxSize()
) {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
}
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.create_profile_button,
onboarding = null,
enabled = canCreateProfile(displayName.value),
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
@@ -255,7 +233,6 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
val modifier = Modifier
.fillMaxWidth()
.heightIn(min = 50.dp)
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
Column(
Modifier
@@ -289,6 +266,7 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
@@ -53,7 +53,7 @@ fun IncomingCallAlertLayout(
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
Column(Modifier.fillMaxWidth().background(color).statusBarsPadding().padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
IncomingCallInfo(invitation, chatModel)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
@@ -529,10 +529,7 @@ fun ChatInfoLayout(
KeyChangeEffect(chat.id) {
scope.launch { scrollState.scrollTo(0) }
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
) {
ColumnWithScrollBar {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -276,7 +276,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun HistoryTab() {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val versions = ciInfo.itemVersions
@@ -300,7 +300,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun QuoteTab(qi: CIQuote) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
@@ -313,7 +313,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun ForwardedFromTab(forwardedFromItem: AChatItem) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView {
@@ -375,7 +375,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun DeliveryTab(memberDeliveryStatuses: List<MemberDeliveryStatus>) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
@@ -12,10 +12,11 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -108,6 +109,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
}
val clipboard = LocalClipboardManager.current
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) {
when (chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
@@ -523,28 +525,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch
)
if (appPlatform.isAndroid) {
val backgroundColor = MaterialTheme.colors.background
val backgroundColorState = rememberUpdatedState(backgroundColor)
LaunchedEffect(Unit) {
snapshotFlow { ModalManager.center.modalCount.value > 0 }
.collect { modalBackground ->
if (modalBackground) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
} else {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, backgroundColorState.value, true, false)
}
}
}
}
}
}
is ChatInfo.ContactConnection -> {
val close = { chatModel.chatId.value = null }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, content = {
ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close)
})
@@ -553,14 +537,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
is ChatInfo.InvalidJSON -> {
val close = { chatModel.chatId.value = null }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, endButtons = { ShareButton { clipboard.shareText(chatInfo.json) } }, content = {
InvalidJSONView(chatInfo.json)
})
@@ -569,10 +548,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
else -> {}
}
}
}
}
@@ -642,81 +621,67 @@ fun ChatLayout(
.desktopOnExternalDrag(
enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
onImage = {
// TODO: file is not saved anywhere?!
val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit()
chatModel.filesToDelete.add(tmpFile)
val uri = tmpFile.toURI()
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(uri), null) }
},
onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } },
onText = {
// Need to parse HTML in order to correctly display the content
//composeState.value = composeState.value.copy(message = composeState.value.message + it)
},
)
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetElevation = 0.dp,
sheetContent = {
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
val setFloatingButton = { button: @Composable () -> Unit ->
floatingButton.value = button
}
Scaffold(
topBar = {
if (selectedChatItems.value == null) {
val chatInfo = chatInfo.value
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
},
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
val wallpaperType = MaterialTheme.wallpaper.type
val backgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, MaterialTheme.colors.background)
val tintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
BoxWithConstraints(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.then(if (wallpaperImage != null)
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor) }
else
Modifier)
.padding(contentPadding)
) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
if (chatInfo != null) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
sheetElevation = 0.dp,
sheetContent = {
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val composeViewHeight = remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chatInfo != null) {
Box(Modifier.fillMaxSize()) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, searchValue,
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
)
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(
Modifier
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
.then(if (oneHandUI.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier)
) {
composeView()
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(true, oneHandUI.value, noAlpha = true)
}
Box(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
}
}
}
@@ -724,7 +689,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(
fun BoxScope.ChatInfoToolbar(
chatInfo: ChatInfo,
back: () -> Unit,
info: () -> Unit,
@@ -876,21 +841,33 @@ fun ChatInfoToolbar(
}
}
}
DefaultTopAppBar(
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chatInfo) },
onTitleClick = if (chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch.value,
onTop = !oneHandUI.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
buttons = { barButtons.forEach { it() } }
)
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight * fontSizeSqrtMultiplier)) {
DefaultDropdownMenu(showMenu) {
menuItems.forEach { it() }
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
val density = LocalDensity.current
val width = remember { mutableStateOf(250.dp) }
val height = remember { mutableStateOf(0.dp) }
DefaultDropdownMenu(
showMenu,
modifier = Modifier.onSizeChanged { with(density) {
width.value = it.width.toDp().coerceAtLeast(250.dp)
if (oneHandUI.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) height.value = it.height.toDp()
} },
offset = DpOffset(-width.value, if (oneHandUI.value) -height.value else AppBarHeight)
) {
if (oneHandUI.value) {
menuItems.asReversed().forEach { it() }
} else {
menuItems.forEach { it() }
}
}
}
}
@@ -934,11 +911,12 @@ private fun ContactVerifiedShield() {
}
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
fun BoxScope.ChatItemsList(
remoteHostId: Long?,
chatInfo: ChatInfo,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeViewHeight: State<Dp>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
@@ -963,7 +941,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
@@ -987,13 +964,18 @@ fun BoxWithConstraintsScope.ChatItemsList(
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() })
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val scrollToItem: State<(Long) -> Unit> = remember {
mutableStateOf(
{ itemId: Long ->
val index = reversedChatItems.value.indexOfFirst { it.id == itemId }
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) }
}
}
)
}
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
LaunchedEffect(chatInfo.id) {
@@ -1011,8 +993,18 @@ fun BoxWithConstraintsScope.ChatItemsList(
VideoPlayerHolder.releaseAll()
}
)
LazyColumnWithScrollBar(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
additionalBarOffset = composeViewHeight
) {
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
val itemScope = rememberCoroutineScope()
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -1020,10 +1012,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val provider = {
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
scope.launch {
itemScope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
-maxHeightRounded
kotlin.math.min(reversedChatItems.value.lastIndex, indexInReversed + 1),
-maxHeight.value
)
}
}
@@ -1036,7 +1028,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@@ -1044,7 +1036,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
if (it == DismissValue.DismissedToStart) {
scope.launch {
itemScope.launch {
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
@@ -1241,16 +1233,17 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
val range = chatViewItemsRange(currIndex, prevHidden)
val reversed = reversedChatItems.value
if (revealed.value && range != null) {
reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1]
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1]
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
}
} else {
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
}
if (i == reversedChatItems.lastIndex) {
if (i == reversed.lastIndex) {
DateSeparator(cItem.meta.itemTs)
}
}
@@ -1258,7 +1251,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
LaunchedEffect(cItem.id) {
scope.launch {
itemScope.launch {
delay(600)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
@@ -1267,10 +1260,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
FloatingButtons(chatModel.chatItems, unreadCount, remoteHostId, chatInfo, searchValue, markRead, setFloatingButton, listState)
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState)
FloatingDate(
Modifier.padding(top = 10.dp).align(Alignment.TopCenter),
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
listState,
)
@@ -1325,87 +1318,65 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
fun BoxScope.FloatingButtons(
chatItems: State<List<ChatItem>>,
unreadCount: State<Int>,
composeViewHeight: State<Dp>,
remoteHostId: Long?,
chatInfo: ChatInfo,
searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect {
firstVisibleIndex = it
firstItemIsVisible = firstVisibleIndex == 0
}
}
LaunchedEffect(listState) {
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
// so separate them into two LaunchedEffects
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.collect {
lastIndexOfVisibleItems = it
}
}
val bottomUnreadCount by remember {
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
val bottomUnreadCount = remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val items = chatItems.value
val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
val from = items.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
if (items.size <= from || from < 0) return@derivedStateOf 0
items.subList(from, items.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
setFloatingButton(
bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
))
}
val showBottomButtonWithCounter = remember { derivedStateOf { bottomUnreadCount.value > 0 && listState.firstVisibleItemIndex != 0 && searchValue.value.isEmpty() } }
val showBottomButtonWithArrow = remember { derivedStateOf { !showBottomButtonWithCounter.value && listState.firstVisibleItemIndex != 0 } }
BottomEndFloatingButton(
bottomUnreadCount,
showBottomButtonWithCounter,
showBottomButtonWithArrow,
composeViewHeight,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
}
)
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
val fabSize = 56.dp
val topUnreadCount by remember {
derivedStateOf { unreadCount.value - bottomUnreadCount }
}
val showButtonWithCounter = topUnreadCount > 0
val height = with(LocalDensity.current) { maxHeight.toPx() }
val topUnreadCount = remember { derivedStateOf { unreadCount.value - bottomUnreadCount.value } }
val showDropDown = remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp).align(Alignment.TopEnd),
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent()).align(Alignment.TopEnd),
topUnreadCount,
showButtonWithCounter,
onClick = { scope.launch { listState.animateScrollBy(height) } },
onClick = { scope.launch { listState.animateScrollBy(maxHeight.value.toFloat()) } },
onLongClick = { showDropDown.value = true }
)
Box {
DefaultDropdownMenu(showDropDown, offset = DpOffset(this@FloatingButtons.maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
val density = LocalDensity.current
val width = remember { mutableStateOf(250.dp) }
DefaultDropdownMenu(
showDropDown,
modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } },
offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent())
) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
@@ -1413,7 +1384,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
val minUnreadItemId = chatModel.chats.value.firstOrNull { it.remoteHostId == remoteHostId && it.id == chatInfo.id }?.chatStats?.minUnreadItemId ?: return@ItemAction
markRead(
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
bottomUnreadCount.value
)
showDropDown.value = false
})
@@ -1475,12 +1446,11 @@ fun MemberImage(member: GroupMember) {
@Composable
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: Int,
showButtonWithCounter: Boolean,
unreadCount: State<Int>,
onClick: () -> Unit,
onLongClick: () -> Unit
) = when {
showButtonWithCounter -> {
unreadCount.value > 0 -> {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
@@ -1490,7 +1460,7 @@ private fun TopEndFloatingButton(
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount),
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
@@ -1500,6 +1470,16 @@ private fun TopEndFloatingButton(
}
}
@Composable
fun topPaddingToContent(): Dp {
val oneHandUI = remember { appPrefs.oneHandUI.state }
return if (oneHandUI.value) {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
} else {
AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
}
}
@Composable
private fun FloatingDate(
modifier: Modifier,
@@ -1509,8 +1489,9 @@ private fun FloatingDate(
var isNearBottom by remember { mutableStateOf(true) }
val lastVisibleItemDate = remember {
derivedStateOf {
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0 && listState.firstVisibleItemIndex >= 0) {
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0) {
val lastFullyVisibleOffset = listState.layoutInfo.viewportEndOffset
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - (listState.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset + item.size <= lastFullyVisibleOffset && item.size > 0 }?.index ?: 0)
val item = chatModel.chatItems.value.getOrNull(lastVisibleChatItemIndex)
val timeZone = TimeZone.currentSystemDefault()
item?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone)
@@ -1697,48 +1678,44 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
showButtonWithArrow: Boolean,
@Composable
private fun BoxScope.BottomEndFloatingButton(
unreadCount: State<Int>,
showButtonWithCounter: State<Boolean>,
showButtonWithArrow: State<Boolean>,
composeViewHeight: State<Dp>,
onClickArrowDown: () -> Unit,
onClickCounter: () -> Unit
): @Composable () -> Unit = when {
showButtonWithCounter -> {
{
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
) = when {
showButtonWithCounter.value -> {
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
showButtonWithArrow -> {
{
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
showButtonWithArrow.value -> {
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
else -> {
{}
}
else -> {}
}
@Composable
@@ -1865,6 +1842,25 @@ private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount:
}
}
fun Modifier.chatViewBackgroundModifier(
colors: Colors,
wallpaper: AppWallpaper,
backgroundGraphicsLayerSize: MutableState<IntSize>?,
backgroundGraphicsLayer: GraphicsLayer?
): Modifier {
val wallpaperImage = wallpaper.type.image
val wallpaperType = wallpaper.type
val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background)
val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
return this
.then(if (wallpaperImage != null)
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) }
else
Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } }
)
}
fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? =
if (currIndex != null && prevHidden != null && prevHidden > currIndex) {
currIndex..prevHidden
@@ -13,12 +13,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.filesToDelete
import chat.simplex.common.model.ChatModel.withChats
@@ -896,7 +898,7 @@ fun ComposeView(
}
}
}
Column(Modifier.background(MaterialTheme.colors.background)) {
Box(Modifier.background(MaterialTheme.colors.background)) {
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
@@ -918,7 +920,7 @@ fun ComposeView(
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
enabled = attachmentEnabled
) {
Icon(
@@ -81,10 +81,7 @@ private fun ContactPreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.contact_preferences))
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
@@ -1,9 +1,11 @@
package chat.simplex.common.views.chat
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
@@ -12,9 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier.fillMaxSize()
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.scan_code))
QRCodeScanner { text ->
verifyCode(text) {
@@ -28,5 +28,6 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: ()
}
}
Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING))
SectionBottomSpacer()
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.helpers.*
@@ -20,11 +21,12 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
val onBackClicked = { selectedChatItems.value = null }
BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0
DefaultTopAppBar(
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
title = {
Text(
@@ -39,10 +41,9 @@ fun SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
)
},
onTitleClick = null,
showSearch = false,
onTop = !oneHandUI.value,
onSearchValueChanged = {},
)
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
}
@Composable
@@ -68,6 +69,8 @@ fun SelectedItemsBottomToolbar(
Modifier
.matchParentSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 2.dp)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.pointerInput(Unit) {
detectGesture {
true
@@ -103,6 +106,7 @@ fun SelectedItemsBottomToolbar(
)
}
}
Divider(Modifier.align(Alignment.TopStart))
}
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
@@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
@@ -15,6 +14,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.*
@@ -61,7 +61,8 @@ fun SendMsgView(
) {
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
Box(Modifier.padding(vertical = if (appPlatform.isAndroid) 8.dp else 6.dp)) {
val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp)
Box(Modifier.padding(padding)) {
val cs = composeState.value
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(composeState.value.inProgress) {
@@ -147,7 +148,7 @@ fun SendMsgView(
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem
) {
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(12.dp))
StartLiveMessageButton(userCanSend) {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
@@ -423,6 +424,7 @@ private fun SendMsgButton(
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
@@ -431,7 +433,7 @@ private fun SendMsgButton(
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
)
.onRightClick { onLongClick?.invoke() },
contentAlignment = Alignment.Center
@@ -454,6 +456,7 @@ private fun SendMsgButton(
@Composable
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
@@ -461,7 +464,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
),
contentAlignment = Alignment.Center
) {
@@ -56,11 +56,7 @@ private fun VerifyCodeLayout(
connectionVerified: Boolean,
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.security_code), withPadding = false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
@@ -130,10 +130,7 @@ fun AddGroupMembersLayout(
}
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.button_add_members))
profileText()
Spacer(Modifier.size(DEFAULT_PADDING))
@@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -283,9 +284,14 @@ fun ModalData.GroupChatInfoLayout(
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) }
}
}
Box {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LazyColumnWithScrollBar(
Modifier
.fillMaxWidth(),
contentPadding = if (oneHandUI.value) {
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
} else {
PaddingValues(top = topPaddingToContent())
},
state = listState
) {
item {
@@ -397,6 +403,11 @@ fun ModalData.GroupChatInfoLayout(
}
}
SectionBottomSpacer()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(oneHandUI.value, oneHandUI.value)
}
}
}
@@ -119,9 +119,7 @@ fun GroupLinkLayout(
)
}
ColumnWithScrollBar(
Modifier,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.group_link))
Text(
stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect),
@@ -313,10 +313,7 @@ fun GroupMemberInfoLayout(
}
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -82,9 +82,7 @@ private fun GroupPreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.group_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
@@ -82,10 +82,9 @@ fun GroupProfileLayout(
}, close)
}
}
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
modifier = Modifier.imePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
@@ -98,9 +97,7 @@ fun GroupProfileLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = closeWithAlert) {
ColumnWithScrollBar(
Modifier
) {
ColumnWithScrollBar {
Column(
Modifier.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING)
@@ -177,7 +174,6 @@ fun GroupProfileLayout(
}
}
}
}
}
private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean =
@@ -95,9 +95,7 @@ private fun GroupWelcomeLayout(
linkMode: SimplexLinkMode,
save: () -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
val editMode = remember { mutableStateOf(true) }
AppBarTitle(stringResource(MR.strings.group_welcome_title))
val wt = rememberSaveable { welcomeText }
@@ -10,6 +10,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.UriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -320,6 +321,8 @@ fun CIMarkdownText(
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose"
const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose"
/**
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
@@ -398,6 +401,70 @@ fun DependentLayout(
}
}
}
// The purpose of this layout is to make measuring of bottom compose view and adapt top lazy column to its size in the same frame (not on the next frame as you would expect).
// So, steps are:
// - measuring the layout: measured height of compose view before this step is 0, it's added to content padding of lazy column (so it's == 0)
// - measured the layout: measured height of compose view now is correct, but it's not yet applied to lazy column content padding (so it's == 0) and lazy column is placed higher than compose view in view with respect to compose view's height
// - on next frame measured height is correct and content padding is the same, lazy column placed to occupy all parent view's size
// - every added/removed line in compose view goes through the same process.
@Composable
fun AdaptingBottomPaddingLayout(
modifier: Modifier = Modifier,
mainLayoutId: String,
expectedHeight: MutableState<Dp>,
content: @Composable () -> Unit
) {
val expected = with(LocalDensity.current) { expectedHeight.value.roundToPx() }
Layout(
content = content,
modifier = modifier
) { measureable, constraints ->
require(measureable.size <= 2) { "Should be exactly one or two elements in this layout, you have ${measureable.size}" }
val mainPlaceable = measureable.firstOrNull { it.layoutId == mainLayoutId }!!.measure(constraints)
val placeables: List<Placeable> = measureable.map {
if (it.layoutId == mainLayoutId)
mainPlaceable
else
it.measure(constraints.copy(maxHeight = if (expected != mainPlaceable.measuredHeight) constraints.maxHeight - mainPlaceable.measuredHeight + expected else constraints.maxHeight)) }
expectedHeight.value = mainPlaceable.measuredHeight.toDp()
layout(constraints.maxWidth, constraints.maxHeight) {
var y = 0
placeables.forEach {
if (it !== mainPlaceable) {
it.place(0, y)
y += it.measuredHeight
} else {
it.place(0, constraints.maxHeight - mainPlaceable.measuredHeight)
y += it.measuredHeight
}
}
}
}
}
@Composable
fun CenteredRowLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measureable, constraints ->
require(measureable.size == 3) { "Should be exactly three elements in this layout, you have ${measureable.size}" }
val first = measureable[0].measure(constraints.copy(minWidth = 0, minHeight = 0))
val third = measureable[2].measure(constraints.copy(minWidth = first.measuredWidth, minHeight = 0))
val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0)))
// Limit width for every other element to width of important element and height for a sum of all elements.
layout(constraints.maxWidth, constraints.maxHeight) {
first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
}
}
}
/*
class EditedProvider: PreviewParameterProvider<Boolean> {
@@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.onGloballyPositioned
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
@@ -58,9 +57,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
DisposableEffectOnGone(
always = {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, Color.Black, false, false)
platform.androidSetStatusAndNavigationBarAppearance(false, false, blackNavBar = true)
chatModel.fullscreenGalleryVisible.value = true
},
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
whenDispose = {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
chatModel.fullscreenGalleryVisible.value = false
},
whenGone = {
playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) }
}
)
@Composable
@@ -10,8 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
@@ -34,6 +33,7 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@@ -41,7 +41,6 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration.Companion.seconds
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
@@ -55,7 +54,7 @@ private fun showNewChatSheet(oneHandUI: State<Boolean>) {
chatModel.newChatSheetVisible.value = false
close()
}
ModalView(close, closeOnTop = !oneHandUI.value) {
ModalView(close, showAppBar = !oneHandUI.value) {
if (appPlatform.isAndroid) {
BackHandler {
close()
@@ -122,11 +121,7 @@ fun ToggleChatListCard() {
SharedPreferenceToggle(
appPrefs.oneHandUI,
enabled = true,
onChange = {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
enabled = true
)
}
}
@@ -154,74 +149,36 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
Scaffold(
topBar = {
if (!oneHandUI.value) {
Column {
ChatListToolbar(
userPickerState,
stopped,
setPerformLA,
)
Divider()
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
Box(Modifier.fillMaxSize()) {
if (oneHandUI.value) {
ChatListWithLoadingScreen(searchText, listState)
Column(Modifier.align(Alignment.BottomCenter)) {
ChatListToolbar(
userPickerState,
listState,
stopped,
setPerformLA,
)
}
},
bottomBar = {
if (oneHandUI.value) {
Column {
Divider()
ChatListToolbar(
userPickerState,
stopped,
setPerformLA,
)
}
} else {
ChatListWithLoadingScreen(searchText, listState)
Column {
ChatListToolbar(
userPickerState,
listState,
stopped,
setPerformLA,
)
}
},
contentColor = LocalContentColor.current,
floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI)
}
},
Modifier
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
}
}
}
) {
Box(Modifier.padding(it)) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
}
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
NewChatSheetFloatingButton(oneHandUI, stopped)
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
if (appPlatform.isDesktop && !oneHandUI.value) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call)
@@ -239,6 +196,46 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
@Composable
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(searchText = searchText, listState)
}
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(
stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats
), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
@Composable
private fun BoxScope.NewChatSheetFloatingButton(oneHandUI: State<Boolean>, stopped: Boolean) {
FloatingActionButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI)
}
},
Modifier
.navigationBarsPadding()
.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
.align(Alignment.BottomEnd)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
@@ -256,7 +253,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, listState: LazyListState, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -265,6 +262,18 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
if (oneHandUI.value) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
if (appPlatform.isDesktop && oneHandUI.value) {
val call = remember { chatModel.activeCall }
if (call.value != null) {
barButtons.add {
val c = call.value
if (c != null) {
ActiveCallInteractiveArea(c)
Spacer(Modifier.width(5.dp))
}
}
}
}
if (!stopped) {
barButtons.add {
IconButton(
@@ -323,7 +332,9 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
}
}
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
val scope = rememberCoroutineScope()
val canScrollToZero = remember { derivedStateOf { listState.firstVisibleItemIndex != 0 || listState.firstVisibleItemScrollOffset != 0 } }
DefaultAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu {
@@ -351,15 +362,14 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
SubscriptionStatusIndicator(
click = {
ModalManager.start.closeModals()
val summary = serversSummary.value
ModalManager.start.showModalCloseable(
endButtons = {
val summary = serversSummary.value
if (summary != null) {
ShareButton {
val json = Json {
prettyPrint = true
}
val text = json.encodeToString(PresentedServersSummary.serializer(), summary)
clipboard.shareText(text)
}
@@ -370,10 +380,10 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
)
}
},
onTitleClick = null,
showSearch = false,
onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null,
onTop = !oneHandUI.value,
onSearchValueChanged = {},
buttons = barButtons
buttons = { barButtons.forEach { it() } }
)
}
@@ -491,74 +501,78 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
Box {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.value.isNotEmpty()) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.value.isNotEmpty()) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Divider(Modifier.align(if (oneHandUI.value) Alignment.TopStart else Alignment.BottomStart))
}
}
@@ -590,8 +604,37 @@ enum class ScrollDirection {
}
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
fun BoxScope.StatusBarBackground() {
if (appPlatform.isAndroid) {
val finalColor = MaterialTheme.colors.background.copy(0.88f)
Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor))
}
}
@Composable
fun BoxScope.NavigationBarBackground(appBarOnBottom: Boolean = false, mixedColor: Boolean, noAlpha: Boolean = false) {
if (appPlatform.isAndroid) {
val barPadding = WindowInsets.navigationBars.asPaddingValues()
val paddingBottom = barPadding.calculateBottomPadding()
val color = if (mixedColor) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) else MaterialTheme.colors.background
val finalColor = color.copy(if (noAlpha) 1f else if (appBarOnBottom) remember { appPrefs.inAppBarsAlpha.state }.value else 0.6f)
Box(Modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
}
}
@Composable
fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = MaterialTheme.colors.background) {
val keyboardState = getKeyboardState()
if (appPlatform.isAndroid && keyboardState.value == KeyboardState.Closed) {
val barPadding = WindowInsets.navigationBars.asPaddingValues()
val paddingBottom = barPadding.calculateBottomPadding()
val finalColor = color.copy(0.6f)
Box(modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
}
}
@Composable
private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
@@ -628,40 +671,45 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
val topPaddingToContent = topPaddingToContent()
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
if (!oneHandUI.value) Modifier.imePadding() else Modifier,
listState,
reverseLayout = oneHandUI.value
) {
item { Spacer(Modifier.height(blankSpaceSize)) }
stickyHeader {
Column(
Modifier
.zIndex(1f)
.offset {
val y = if (searchText.value.text.isEmpty()) {
val offsetMultiplier = if (oneHandUI.value) 1 else -1
if (
(oneHandUI.value && scrollDirection == ScrollDirection.Up) ||
(appPlatform.isAndroid && keyboardState == KeyboardState.Opened)
) {
0
} else if (listState.firstVisibleItemIndex == 0) offsetMultiplier * listState.firstVisibleItemScrollOffset else offsetMultiplier * 1000
val offsetMultiplier = if (oneHandUI.value) 1 else -1
val y = if (searchText.value.text.isNotEmpty() || (appPlatform.isAndroid && keyboardState == KeyboardState.Opened) || scrollDirection == ScrollDirection.Up) {
if (listState.firstVisibleItemIndex == 0) -offsetMultiplier * listState.firstVisibleItemScrollOffset
else -offsetMultiplier * blankSpaceSize.roundToPx()
} else {
0
when (listState.firstVisibleItemIndex) {
0 -> 0
1 -> offsetMultiplier * listState.firstVisibleItemScrollOffset
else -> offsetMultiplier * 1000
}
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background),
.background(MaterialTheme.colors.background)
) {
if (oneHandUI.value) {
Divider()
}
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
if (!oneHandUI.value) {
Divider()
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
}
}
}
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() > 1) {
if (!oneHandUICardShown.value && chats.size > 1) {
item {
ToggleChatListCard()
}
@@ -672,17 +720,30 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
} }
ChatListNavLinkView(chat, nextChatSelected)
}
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() <= 1) {
if (!oneHandUICardShown.value && chats.size <= 1) {
item {
ToggleChatListCard()
}
}
if (appPlatform.isAndroid) {
item { Spacer(if (oneHandUI.value) Modifier.windowInsetsTopHeight(WindowInsets.statusBars) else Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) }
}
}
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(oneHandUI.value, true)
}
if (!oneHandUICardShown.value) {
LaunchedEffect(chats.size) {
if (chats.size >= 3) appPrefs.oneHandUICardShown.set(true)
}
}
}
fun filteredChats(
@@ -727,3 +788,7 @@ private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
}
@@ -620,9 +620,7 @@ fun ModalData.SMPServerSummaryView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
@@ -645,9 +643,7 @@ fun ModalData.DetailedXFTPStatsView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -671,9 +667,7 @@ fun ModalData.DetailedSMPStatsView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -697,9 +691,7 @@ fun ModalData.XFTPServerSummaryView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -715,9 +707,7 @@ fun ModalData.XFTPServerSummaryView(
@Composable
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
var showUserSelection by remember { mutableStateOf(false) }
val selectedUserCategory =
remember { stateGetOrPut("selectedUserCategory") { PresentedUserCategory.ALL_USERS } }
@@ -11,10 +11,13 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.themedBackground
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR
@@ -22,26 +25,7 @@ import chat.simplex.res.MR
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold(
contentColor = LocalContentColor.current,
topBar = {
if (!oneHandUI.value) {
Column {
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider()
}
}
},
bottomBar = {
if (oneHandUI.value) {
Column {
Divider()
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
) {
Box(Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
val sharedContent = chatModel.sharedContent.value
var isMediaOrFileAttachment = false
var isVoice = false
@@ -69,22 +53,24 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
}
null -> {}
}
Box(Modifier.padding(it)) {
Column(
modifier = Modifier.fillMaxSize()
) {
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
chatModel,
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink,
)
} else {
EmptyList()
}
}
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
chatModel,
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink,
)
} else {
EmptyList()
}
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(oneHandUI.value, true)
}
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
@@ -108,7 +94,6 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
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 {
@@ -118,13 +103,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
ModalManager.start.showCustomModal { close ->
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
showSearch = true,
searchAlwaysVisible = true,
onSearchValueChanged = { search.value = it },
content = {
ActiveProfilePicker(
search = search,
@@ -148,31 +133,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
})
}
}
if (chatModel.chats.value.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
}
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
DefaultTopAppBar(
DefaultAppBar(
navigationButton = navButton,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -191,8 +153,29 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
},
onTitleClick = null,
showSearch = showSearch,
onTop = !remember { appPrefs.oneHandUI.state }.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
buttons = {
if (chatModel.chats.value.size >= 8) {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
if (stopped) {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
)
}
@@ -211,8 +194,13 @@ private fun ShareList(
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
}
}
val topPaddingToContent = topPaddingToContent()
LazyColumnWithScrollBar(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.then(if (oneHandUI.value) Modifier.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Vertical)) else Modifier).imePadding(),
contentPadding = PaddingValues(
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
),
reverseLayout = oneHandUI.value
) {
items(chats) { chat ->
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
@@ -137,12 +138,16 @@ fun UserPicker(
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
val iconColor = MaterialTheme.colors.secondaryVariant
val background = if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface
PlatformUserPicker(
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
.background(if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface)
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true, ambientColor = background) else Modifier)
.padding(top = if (appPlatform.isDesktop && oneHandUI.value) 7.dp else 0.dp)
.background(background)
.padding(bottom = USER_PICKER_SECTION_SPACING - DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
pickerState = userPickerState
) {
@@ -198,12 +203,13 @@ fun UserPicker(
UserPickerUsersSection(
users = users,
onUserClicked = onUserClicked,
iconColor = iconColor,
stopped = stopped
)
}
} else if (currentUser != null) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, stopped = stopped)
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
}
}
}
@@ -234,6 +240,7 @@ fun UserPicker(
Column(modifier = Modifier.padding(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)) {
UserPickerUsersSection(
users = inactiveUsers,
iconColor = iconColor,
onUserClicked = onUserClicked,
stopped = stopped
)
@@ -261,20 +268,32 @@ fun UserPicker(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
{
doWithAuth(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
val profileHidden = rememberSaveable { mutableStateOf(false) }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = { UserProfilesView(chatModel, search, profileHidden) })
}
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
val search = rememberSaveable { mutableStateOf("") }
val profileHidden = rememberSaveable { mutableStateOf(false) }
val authorized = remember { stateGetOrPut("authorized") { false } }
ModalView(
{ close() },
showSearch = true,
searchAlwaysVisible = true,
onSearchValueChanged = {
search.value = it
},
content = {
UserProfilesView(chatModel, search, profileHidden) { block ->
if (authorized.value) {
block()
} else {
doWithAuth(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
authorized.value = true
block()
}
}
}
})
}
},
disabled = stopped
@@ -403,26 +422,35 @@ fun UserProfilePickerItem(
UserProfileRow(u, enabled)
if (u.activeUser) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (u.hidden) {
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else if (unreadCount > 0) {
Box(
contentAlignment = Alignment.Center
) {
Text(
unreadCountStr(unreadCount),
color = Color.White,
fontSize = 10.sp,
modifier = Modifier
.background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
.padding(2.dp)
.badgeLayout()
)
}
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
Box(Modifier.size(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (unreadCount > 0) {
Box(
contentAlignment = Alignment.Center,
) {
Text(
unreadCountStr(unreadCount),
color = Color.White,
fontSize = 10.sp,
modifier = Modifier
.background(if (u.showNtfs) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
.padding(2.dp)
.badgeLayout()
)
}
if (u.hidden) {
Spacer(Modifier.width(8.dp))
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
}
} else if (u.hidden) {
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
Box(Modifier.size(20.dp))
}
}
}
}
}
@@ -519,6 +547,7 @@ private fun DevicePickerRow(
@Composable
expect fun UserPickerUsersSection(
users: List<UserInfo>,
iconColor: Color,
stopped: Boolean,
onUserClicked: (user: User) -> Unit,
)
@@ -46,9 +46,7 @@ fun ChatArchiveLayout(
saveArchive: () -> Unit,
deleteArchiveAlert: () -> Unit
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(title)
SectionView(stringResource(MR.strings.chat_archive_section)) {
SettingsActionItem(
@@ -203,7 +203,7 @@ fun DatabaseEncryptionLayout(
Layout()
}
} else {
ColumnWithScrollBar(Modifier.fillMaxWidth(), maxIntrinsicSize = true) {
ColumnWithScrollBar(maxIntrinsicSize = true) {
Layout()
}
}
@@ -77,10 +77,7 @@ fun DatabaseErrorView(
Text(String.format(generalGetString(MR.strings.database_migrations), ms.joinToString(", ")))
}
ColumnWithScrollBar(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
ColumnWithScrollBarNoAppBar(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
when (val status = chatDbStatus.value) {
is DBMigrationResult.ErrorNotADatabase ->
@@ -156,9 +156,7 @@ fun DatabaseLayout(
val stopped = !runChat
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_chat_database))
if (!chatModel.desktopNoUserNoRemote) {
@@ -0,0 +1,71 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
LaunchedEffect(title) {
handler?.title?.value = title
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
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))
Column {
Text(
title,
Modifier
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
.graphicsLayer {
alpha = bottomTitleAlpha(connection)
},
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
textAlign = TextAlign.Start
)
if (hostDevice != null) {
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
alpha = bottomTitleAlpha(connection)
}) {
HostDeviceTitle(hostDevice)
}
}
Spacer(Modifier.height(bottomPadding))
}
}
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@@ -0,0 +1,139 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
keyboardInset: WindowInsets,
handler: AppBarHandler?,
blurRadius: State<Int>,
prefAlpha: State<Float>,
keyboardCoversBar: Boolean,
onTop: Boolean,
density: Density
): Modifier {
val graphicsLayer = handler?.graphicsLayer
val backgroundGraphicsLayer = handler?.backgroundGraphicsLayer
val backgroundGraphicsLayerSize = handler?.backgroundGraphicsLayerSize
if (handler == null || graphicsLayer == null || backgroundGraphicsLayer == null || blurRadius.value == 0 || prefAlpha.value == 1f || backgroundGraphicsLayerSize === null)
return this
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
// this is more performant version than for Android but can't be used on desktop because on first frame it shows transparent view
// which is very noticeable on desktop and unnoticeable on Android
private fun Modifier.androidBlurredModifier(
keyboardInset: WindowInsets,
blurRadius: Int,
keyboardCoversBar: Boolean,
onTop: Boolean,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius > 0) BlurEffect(blurRadius.dp.toPx(), blurRadius.dp.toPx()) else null
clip = blurRadius > 0
}
.graphicsLayer {
if (!onTop) {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translationY = -bgSize + size.height + keyboardHeightCovered
}
}
.drawBehind {
drawRect(Color.Black)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
} else {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
}
.graphicsLayer {
if (!onTop) {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translationY -= -bgSize + size.height + keyboardHeightCovered
}
}
private fun Modifier.desktopBlurredModifier(
keyboardInset: WindowInsets,
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(Color.Black)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
}
}
@@ -1,11 +1,13 @@
package chat.simplex.common.views.helpers
import androidx.compose.runtime.*
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
@@ -381,7 +383,14 @@ private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, siz
return bitmap
}
fun CacheDrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperType, background: Color, tint: Color): DrawResult {
fun CacheDrawScope.chatViewBackground(
image: ImageBitmap,
imageType: WallpaperType,
background: Color,
tint: Color,
graphicsLayerSize: MutableState<IntSize>? = null,
backgroundGraphicsLayer: GraphicsLayer? = null
): DrawResult {
val imageScale = if (imageType is WallpaperType.Preset) {
(imageType.scale ?: 1f) * imageType.predefinedImageScale
} else if (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT) {
@@ -396,53 +405,55 @@ fun CacheDrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperTy
}
return onDrawBehind {
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
drawRect(background)
when (imageType) {
is WallpaperType.Preset -> drawImage(image)
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
WallpaperScaleType.REPEAT -> drawImage(image)
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
clipRect {
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
val scaledWidth = (image.width * scale.scaleX).roundToInt()
val scaledHeight = (image.height * scale.scaleY).roundToInt()
// Large image will cause freeze
if (image.width > 4320 || image.height > 4320) return@clipRect
copyBackgroundToAppBar(graphicsLayerSize, backgroundGraphicsLayer) {
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
drawRect(background)
when (imageType) {
is WallpaperType.Preset -> drawImage(image)
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
WallpaperScaleType.REPEAT -> drawImage(image)
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
clipRect {
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
val scaledWidth = (image.width * scale.scaleX).roundToInt()
val scaledHeight = (image.height * scale.scaleY).roundToInt()
// Large image will cause freeze
if (image.width > 4320 || image.height > 4320) return@clipRect
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
if (scaleType == WallpaperScaleType.FIT) {
if (scaledWidth < size.width) {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while (x > 0) {
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while (x < size.width) {
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while (y > 0) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while (y < size.height) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y += scaledHeight
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
if (scaleType == WallpaperScaleType.FIT) {
if (scaledWidth < size.width) {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while (x > 0) {
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while (x < size.width) {
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while (y > 0) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while (y < size.height) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y += scaledHeight
}
}
}
}
drawRect(tint)
}
drawRect(tint)
}
is WallpaperType.Empty -> {}
}
is WallpaperType.Empty -> {}
}
}
}
@@ -19,6 +19,8 @@ fun ChooseAttachmentView(attachmentOption: MutableState<AttachmentOption?>, hide
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
@@ -1,181 +0,0 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, barPaddingValues: PaddingValues = PaddingValues(horizontal = AppBarHorizontalPadding), endButtons: @Composable RowScope.() -> Unit = {}) {
var rowModifier = Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
if (!closeBarTitle.isNullOrEmpty()) {
rowModifier = rowModifier.background(themeBackgroundMix)
}
val handler = LocalAppBarHandler.current
val connection = LocalAppBarHandler.current?.connection
val title = remember(handler?.title?.value) { handler?.title ?: mutableStateOf("") }
Column(
verticalArrangement = arrangement,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.drawWithCache {
val backgroundColor = if (appPlatform.isDesktop && connection != null) themeBackgroundMix.copy(alpha = topTitleAlpha(connection)) else Color.Transparent
onDrawBehind {
if (appPlatform.isDesktop) {
drawRect(backgroundColor)
}
}
}
) {
Row(
modifier = Modifier.padding(barPaddingValues),
content = {
Row(
rowModifier,
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else {
Spacer(Modifier)
}
if (!closeBarTitle.isNullOrEmpty()) {
Row(
Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
closeBarTitle,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
} else if (title.value.isNotEmpty() && connection != null) {
Row(
Modifier
.padding(start = if (showClose) 0.dp else DEFAULT_PADDING_HALF)
.weight(1f) // hides the title if something wants full width (eg, search field in chat profiles screen)
.graphicsLayer {
alpha = topTitleAlpha((connection))
}
.padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
title.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} else {
Spacer(Modifier.weight(1f))
}
Row {
endButtons()
}
}
}
)
if (closeBarTitle.isNullOrEmpty() && title.value.isNotEmpty() && connection != null) {
Divider(
Modifier
.graphicsLayer {
alpha = topTitleAlpha(connection)
}
)
}
}
}
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
LaunchedEffect(title) {
handler?.title?.value = title
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
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))
Column {
Text(
title,
Modifier
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
.graphicsLayer {
alpha = bottomTitleAlpha(connection)
},
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
textAlign = TextAlign.Start
)
if (hostDevice != null) {
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
alpha = bottomTitleAlpha(connection)
}) {
HostDeviceTitle(hostDevice)
}
}
Spacer(Modifier.height(bottomPadding))
}
}
private fun topTitleAlpha(connection: CollapsingAppBarNestedScrollConnection) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, 1f)
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
CloseSheetBar(close = {})
}
}
@@ -3,15 +3,67 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
val LocalAppBarHandler: ProvidableCompositionLocal<AppBarHandler?> = staticCompositionLocalOf { null }
@Composable
fun rememberAppBarHandler(key1: Any? = null, key2: Any? = null, keyboardCoversBar: Boolean = true): AppBarHandler {
val graphicsLayer = rememberGraphicsLayer()
val backgroundGraphicsLayer = rememberGraphicsLayer()
return remember(key1, key2) { AppBarHandler(graphicsLayer, backgroundGraphicsLayer, keyboardCoversBar) }
}
@Composable
fun adjustAppBarHandler(handler: AppBarHandler): AppBarHandler {
val graphicsLayer = rememberGraphicsLayer()
val backgroundGraphicsLayer = rememberGraphicsLayer()
if (handler.graphicsLayer == null || handler.graphicsLayer?.isReleased == true || handler.backgroundGraphicsLayer?.isReleased == true) {
handler.graphicsLayer = graphicsLayer
handler.backgroundGraphicsLayer = backgroundGraphicsLayer
}
return handler
}
fun Modifier.copyViewToAppBar(blurRadius: Int, graphicsLayer: GraphicsLayer?): Modifier {
return if (blurRadius > 0 && graphicsLayer != null) {
this.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
}
drawLayer(graphicsLayer)
}
} else this
}
fun DrawScope.copyBackgroundToAppBar(graphicsLayerSize: MutableState<IntSize>?, backgroundGraphicsLayer: GraphicsLayer?, scope: DrawScope.() -> Unit) {
val blurRadius = appPrefs.appearanceBarsBlurRadius.get()
if (blurRadius > 0 && graphicsLayerSize != null && backgroundGraphicsLayer != null) {
graphicsLayerSize.value = backgroundGraphicsLayer.size
backgroundGraphicsLayer.record {
scope()
}
drawLayer(backgroundGraphicsLayer)
} else {
scope()
}
}
@Stable
class AppBarHandler(
var graphicsLayer: GraphicsLayer?,
var backgroundGraphicsLayer: GraphicsLayer?,
val keyboardCoversBar: Boolean = true,
listState: LazyListState = LazyListState(0, 0),
scrollState: ScrollState = ScrollState(initial = 0)
) {
@@ -24,6 +76,8 @@ class AppBarHandler(
val connection = CollapsingAppBarNestedScrollConnection()
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -3,7 +3,6 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
@@ -22,13 +21,11 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.database.PassphraseStrength
import chat.simplex.common.views.database.validKey
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DefaultBasicTextField(
modifier: Modifier,
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun DefaultDropdownMenu(
showMenu: MutableState<Boolean>,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
dropdownMenuItems: (@Composable () -> Unit)?
) {
@@ -23,7 +24,7 @@ fun DefaultDropdownMenu(
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
modifier = Modifier
modifier = modifier
.widthIn(min = 250.dp)
.background(MaterialTheme.colors.surface)
.padding(vertical = 4.dp),
@@ -3,44 +3,120 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.CenteredRowLayout
import chat.simplex.res.MR
import kotlin.math.absoluteValue
@Composable
fun DefaultTopAppBar(
fun DefaultAppBar(
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
title: (@Composable () -> Unit)? = null,
fixedTitleText: String? = null,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
onSearchValueChanged: (String) -> Unit,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
onTop: Boolean,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
onSearchValueChanged: (String) -> Unit = {},
buttons: @Composable RowScope.() -> Unit = {},
) {
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
val modifier = if (!showSearch) {
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
} else Modifier
} else Modifier.imePadding()
TopAppBar(
modifier = modifier,
title = {
if (!showSearch) {
title?.invoke()
} else {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = false, onValueChange = onSearchValueChanged)
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
val handler = LocalAppBarHandler.current
val connection = LocalAppBarHandler.current?.connection
val titleText = remember(handler?.title?.value, fixedTitleText) {
if (fixedTitleText != null) {
mutableStateOf(fixedTitleText)
} else {
handler?.title ?: mutableStateOf("")
}
}
val keyboardInset = WindowInsets.ime
Box(modifier) {
val density = LocalDensity.current
val blurRadius = remember { appPrefs.appearanceBarsBlurRadius.state }
Box(Modifier
.matchParentSize()
.blurredBackgroundModifier(keyboardInset, handler, blurRadius, prefAlpha, handler?.keyboardCoversBar == true, onTop, density)
.drawWithCache {
// store it as a variable, don't put it inside if without holding it here. Compiler don't see it changes otherwise
val alpha = prefAlpha.value
val backgroundColor = if (title != null || fixedTitleText != null || connection == null || !onTop) {
themeBackgroundMix.copy(alpha)
} else {
themeBackgroundMix.copy(topTitleAlpha(false, connection))
}
onDrawBehind {
drawRect(backgroundColor)
}
}
},
backgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f),
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else emptyList(),
centered = !showSearch,
)
Box(
Modifier
.fillMaxWidth()
.then(if (!onTop) Modifier.navigationBarsPadding() else Modifier)
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
) {
AppBar(
title = {
if (showSearch) {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
} else if (title != null) {
title()
} else if (titleText.value.isNotEmpty() && connection != null) {
Row(
Modifier
.graphicsLayer {
alpha = if (fixedTitleText != null) 1f else topTitleAlpha(true, connection)
}
) {
Text(
titleText.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else {{}},
centered = !showSearch && (title != null || !onTop),
onTop = onTop,
)
AppBarDivider(onTop, title != null || fixedTitleText != null, connection)
}
}
}
@Composable
fun CallAppBar(
title: @Composable () -> Unit,
onBack: () -> Unit
) {
AppBar(
title,
navigationIcon = { NavigationButtonBack(tintColor = Color(0xFFFFFFD8), onButtonClicked = onBack) },
centered = false,
onTop = true
)
}
@@ -83,58 +159,107 @@ fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
}
@Composable
private fun TopAppBar(
private fun BoxScope.AppBarDivider(onTop: Boolean, fixedAlpha: Boolean, connection: CollapsingAppBarNestedScrollConnection?) {
if (connection != null) {
Divider(
Modifier
.align(if (onTop) Alignment.BottomStart else Alignment.TopStart)
.graphicsLayer {
alpha = if (!onTop || fixedAlpha) 1f else topTitleAlpha(false, connection, 1f)
}
)
} else {
Divider(Modifier.align(if (onTop) Alignment.BottomStart else Alignment.TopStart))
}
}
@Composable
private fun AppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
backgroundColor: Color = MaterialTheme.colors.primarySurface,
buttons: @Composable RowScope.() -> Unit = {},
centered: Boolean,
onTop: Boolean,
) {
Box(
modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
.background(backgroundColor)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
val adjustedModifier = modifier
.then(if (onTop) Modifier.statusBarsPadding() else Modifier)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.fillMaxWidth()
.padding(horizontal = AppBarHorizontalPadding)
if (centered) {
AppBarCenterAligned(adjustedModifier, title, navigationIcon, buttons)
} else {
AppBarStartAligned(adjustedModifier, title, navigationIcon, buttons)
}
}
@Composable
private fun AppBarStartAligned(
modifier: Modifier,
title: @Composable () -> Unit,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit
) {
Row(
modifier,
verticalAlignment = Alignment.CenterVertically
) {
if (navigationIcon != null) {
Row(
Modifier
.fillMaxHeight()
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
verticalAlignment = Alignment.CenterVertically,
content = navigationIcon
)
navigationIcon()
Spacer(Modifier.width(AppBarHorizontalPadding))
} else {
Spacer(Modifier.width(DEFAULT_PADDING))
}
Row(Modifier
.weight(1f)
.padding(end = DEFAULT_PADDING_HALF)
) {
title()
}
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
buttons.forEach { it() }
}
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
val endPadding = (buttons.size * 50f).dp
Box(
Modifier
.fillMaxWidth()
.padding(
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
),
contentAlignment = Alignment.Center
) {
title()
buttons()
}
}
}
@Composable
private fun AppBarCenterAligned(
modifier: Modifier,
title: @Composable () -> Unit,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit,
) {
CenteredRowLayout(modifier) {
if (navigationIcon != null) {
Row(
Modifier.padding(end = AppBarHorizontalPadding),
verticalAlignment = Alignment.CenterVertically,
content = navigationIcon
)
} else {
Spacer(Modifier)
}
Row(
Modifier.padding(end = DEFAULT_PADDING_HALF)
) {
title()
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
buttons()
}
}
}
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
val AppBarHorizontalPadding = 2.dp
@@ -6,7 +6,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -107,6 +106,7 @@ fun <T> ExposedDropDownSettingWithIcon(
expanded.value = !expanded.value && enabled.value
}
) {
val ripple = remember { ripple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -115,7 +115,7 @@ fun <T> ExposedDropDownSettingWithIcon(
onClick = {},
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled.value
),
contentAlignment = Alignment.Center
@@ -6,12 +6,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.StatusBarBackground
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
@@ -21,24 +23,40 @@ import kotlin.math.sqrt
fun ModalView(
close: () -> Unit,
showClose: Boolean = true,
showAppBar: Boolean = true,
enableClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
background: Color = Color.Unspecified,
modifier: Modifier = Modifier,
closeOnTop: Boolean = true,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
onSearchValueChanged: (String) -> Unit = {},
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
if (showClose) {
if (showClose && showAppBar) {
BackHandler(enabled = enableClose, onBack = close)
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
if (closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
}
Box(if (background != Color.Unspecified) Modifier.background(background) else Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
Box(modifier = modifier) {
content()
}
if (showAppBar) {
if (oneHandUI.value) {
StatusBarBackground()
}
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
DefaultAppBar(
navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null,
onTop = !oneHandUI.value,
showSearch = showSearch,
searchAlwaysVisible = searchAlwaysVisible,
onSearchValueChanged = onSearchValueChanged,
buttons = endButtons
)
}
}
}
}
}
@@ -47,7 +65,7 @@ enum class ModalPlacement {
START, CENTER, END, FULLSCREEN
}
class ModalData() {
class ModalData(val keyboardCoversBar: Boolean = true) {
private val state = mutableMapOf<String, MutableState<Any?>>()
fun <T> stateGetOrPut (key: String, default: () -> T): MutableState<T> =
state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState<T>
@@ -55,7 +73,7 @@ class ModalData() {
fun <T> stateGetOrPutNullable (key: String, default: () -> T?): MutableState<T?> =
state.getOrPut(key) { mutableStateOf(default() as Any?) } as MutableState<T?>
val appBarHandler = AppBarHandler()
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
}
class ModalManager(private val placement: ModalPlacement? = null) {
@@ -69,23 +87,21 @@ class ModalManager(private val placement: ModalPlacement? = null) {
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
val data = ModalData()
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal { close ->
ModalView(close, showClose = showClose, closeOnTop = closeOnTop, endButtons = endButtons, content = { data.content() })
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
val data = ModalData()
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, content = { data.content(close) })
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
}
}
fun showCustomModal(animated: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showCustomModal")
val data = ModalData()
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
if (toRemove.isNotEmpty()) {
@@ -146,9 +162,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
modalViews.lastOrNull()?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
}
}
@@ -164,9 +178,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
) {
modalViews.getOrNull(it - 1)?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
}
}
@@ -2,7 +2,7 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
@@ -18,12 +18,9 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@@ -38,6 +35,7 @@ fun SearchTextField(
placeholder: String = stringResource(MR.strings.search_verb),
enabled: Boolean = true,
trailingContent: @Composable (() -> Unit)? = null,
reducedCloseButtonPadding: Dp = 0.dp,
onValueChange: (String) -> Unit
) {
val focusRequester = remember { FocusRequester() }
@@ -81,15 +79,20 @@ fun SearchTextField(
)
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
val textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
// sizing is done differently on Android and desktop in order to have the same height of search and compose view on desktop
// see PlatformTextField.desktop + SendMsgView
val padding = if (appPlatform.isAndroid) PaddingValues() else PaddingValues(top = 3.dp, bottom = 4.dp)
BasicTextField(
value = searchText.value,
modifier = modifier
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.focusRequester(focusRequester)
.padding(padding)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
minHeight = if (appPlatform.isAndroid) TextFieldDefaults.MinHeight else 0.dp
),
onValueChange = {
searchText.value = it
@@ -100,18 +103,14 @@ fun SearchTextField(
visualTransformation = VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
singleLine = true,
textStyle = TextStyle(
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Normal,
fontSize = 15.sp
),
textStyle = textStyle,
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = searchText.value.text,
innerTextField = innerTextField,
placeholder = {
Text(placeholder, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
trailingIcon = if (searchText.value.text.isNotEmpty()) {{
IconButton({
@@ -121,7 +120,7 @@ fun SearchTextField(
}
searchText.value = TextFieldValue("");
onValueChange("")
}) {
}, Modifier.offset(x = reducedCloseButtonPadding)) {
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
}
}} else trailingContent,
@@ -57,7 +57,6 @@ fun TextEditor(
) {
val textFieldModifier = modifier
.fillMaxWidth()
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
.padding(10.dp)
@@ -87,6 +86,7 @@ fun TextEditor(
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
@@ -32,10 +32,7 @@ fun ModalData.UserWallpaperEditor(
globalThemeUsed: MutableState<Boolean>,
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
) {
ColumnWithScrollBar {
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
var showMore by remember { stateGetOrPut("showMore") { false } }
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
@@ -231,10 +228,7 @@ fun ModalData.ChatWallpaperEditor(
globalThemeUsed: MutableState<Boolean>,
save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
) {
ColumnWithScrollBar {
val applyToMode = remember { stateGetOrPutNullable("applyToMode") { applyToMode } }
var showMore by remember { stateGetOrPut("showMore") { false } }
val themeModeOverride = remember { stateGetOrPut("themeModeOverride") { theme } }
@@ -149,9 +149,7 @@ private fun MigrateFromDeviceLayout(
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
ColumnWithScrollBar(
Modifier.fillMaxSize(), maxIntrinsicSize = true
) {
ColumnWithScrollBar(maxIntrinsicSize = true) {
AppBarTitle(stringResource(MR.strings.migrate_from_device_title))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver)
SectionBottomSpacer()
@@ -162,9 +162,7 @@ private fun ModalData.MigrateToDeviceLayout(
close: () -> Unit,
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
ColumnWithScrollBar(
Modifier.fillMaxSize(), maxIntrinsicSize = true
) {
ColumnWithScrollBar(maxIntrinsicSize = true) {
AppBarTitle(stringResource(MR.strings.migrate_to_device_title))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close)
SectionBottomSpacer()
@@ -15,9 +15,7 @@ import chat.simplex.res.MR
@Composable
fun AddContactLearnMore(close: () -> Unit) {
ColumnWithScrollBar(
Modifier.padding(horizontal = DEFAULT_PADDING),
) {
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.one_time_link), withPadding = false)
ReadableText(MR.strings.scan_qr_to_connect_to_contact)
ReadableText(MR.strings.if_you_cant_meet_in_person)
@@ -84,10 +84,9 @@ fun AddGroupLayout(
val focusRequester = remember { FocusRequester() }
val incognito = remember { mutableStateOf(incognitoPref.get()) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
modifier = Modifier.imePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
@@ -100,11 +99,7 @@ fun AddGroupLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = close) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId))
Box(
Modifier
@@ -122,7 +117,7 @@ fun AddGroupLayout(
}
}
}
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(MR.strings.group_display_name_field),
fontSize = 16.sp
@@ -134,7 +129,9 @@ fun AddGroupLayout(
}
}
}
ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester)
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester)
}
Spacer(Modifier.height(8.dp))
SettingsActionItem(
@@ -170,7 +167,6 @@ fun AddGroupLayout(
}
}
}
}
}
fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim())
@@ -89,7 +89,7 @@ private fun ContactConnectionInfoLayout(
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_theater_comedy_filled),
text = null,
click = { ModalManager.start.showModal { IncognitoView() } },
click = { ModalManager.end.showModal { IncognitoView() } },
iconColor = Indigo,
extraPadding = false
) {
@@ -105,9 +105,7 @@ private fun ContactConnectionInfoLayout(
}
}
ColumnWithScrollBar(
Modifier,
) {
ColumnWithScrollBar {
AppBarTitle(
stringResource(
if (contactConnection.initiated) MR.strings.you_invited_a_contact
@@ -1,9 +1,7 @@
package chat.simplex.common.views.newchat
import SectionDivider
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview
@@ -14,8 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.*
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
@@ -32,56 +29,43 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.ScrollDirection
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.contacts.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import java.net.URI
@Composable
fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val keyboardState by getKeyboardState()
val showToolbarInOneHandUI = remember { derivedStateOf { keyboardState == KeyboardState.Closed && oneHandUI.value } }
Scaffold(
bottomBar = {
if (showToolbarInOneHandUI.value) {
Column {
Divider()
CloseSheetBar(
close = close,
showClose = true,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) },
arrangement = Arrangement.Bottom,
closeBarTitle = generalGetString(MR.strings.new_message),
barPaddingValues = PaddingValues(horizontal = 0.dp)
)
}
}
Box {
val closeAll = { ModalManager.start.closeModals() }
Column(modifier = Modifier.fillMaxSize()) {
NewChatSheetLayout(
addContact = {
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll) }
},
scanPaste = {
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
},
createGroup = {
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
},
rh = rh,
close = close
)
}
) {
Column(
modifier = Modifier.fillMaxSize().padding(it)
) {
val closeAll = { ModalManager.start.closeModals() }
Column(modifier = Modifier.fillMaxSize()) {
NewChatSheetLayout(
addContact = {
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) }
},
scanPaste = {
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
},
createGroup = {
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
},
rh = rh,
close = close
if (oneHandUI.value) {
Column(Modifier.align(Alignment.BottomCenter)) {
DefaultAppBar(
navigationButton = { NavigationButtonBack(onButtonClicked = close) },
fixedTitleText = generalGetString(MR.strings.new_message),
onTop = false,
)
}
}
@@ -187,168 +171,258 @@ private fun ModalData.NewChatSheetLayout(
derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) }
}
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
listState,
reverseLayout = oneHandUI.value
) {
if (!oneHandUI.value) {
item {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.new_message),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
val actionButtonsOriginal = listOf(
Triple(
painterResource(MR.images.ic_add_link),
stringResource(MR.strings.add_contact_tab),
addContact,
),
Triple(
painterResource(MR.images.ic_qr_code),
if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link),
scanPaste,
),
Triple(
painterResource(MR.images.ic_group),
stringResource(MR.strings.create_group_button),
createGroup,
)
)
@Composable
fun DeletedChatsItem(actionButtons: List<Triple<Painter, String, () -> Unit>>) {
if (searchText.value.text.isEmpty()) {
Spacer(Modifier.padding(bottom = 27.dp))
}
if (searchText.value.text.isEmpty()) {
Row {
SectionView {
actionButtons.map {
NewChatButton(
icon = it.first,
text = it.second,
click = it.third,
)
}
}
}
if (deletedChats.isNotEmpty()) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
showAppBar = !oneHandUI.value,
) {
if (oneHandUI.value) {
BackHandler(onBack = closeDeletedChats)
}
DeletedContactsView(rh = rh, closeDeletedChats = closeDeletedChats, close = {
ModalManager.start.closeModals()
})
}
}
}
) {
Icon(
painterResource(MR.images.ic_inventory_2),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(false)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
}
}
}
}
}
@Composable
fun NoFilteredContactsItem() {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
}
stickyHeader {
Column(
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
val offsetMultiplier = if (oneHandUI.value) 1 else -1
}
if (
(oneHandUI.value && scrollDirection == ScrollDirection.Up) ||
(appPlatform.isAndroid && keyboardState == KeyboardState.Opened)
) {
0
} else if (oneHandUI.value && listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset
} else if (!oneHandUI.value && listState.firstVisibleItemIndex == 0) {
0
} else if (!oneHandUI.value && listState.firstVisibleItemIndex == 1) {
-listState.firstVisibleItemScrollOffset
@Composable
fun OneHandLazyColumn() {
val blankSpaceSize = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier
LazyColumnWithScrollBar(
state = listState,
reverseLayout = oneHandUI.value
) {
item { Spacer(Modifier.height(blankSpaceSize)) }
stickyHeader {
val scrolledSomething by remember { derivedStateOf { listState.firstVisibleItemScrollOffset > 0 || listState.firstVisibleItemIndex > 0 } }
Column(
Modifier
.zIndex(1f)
.offset {
val y = if (searchText.value.text.isNotEmpty() || (appPlatform.isAndroid && keyboardState == KeyboardState.Opened)) {
if (listState.firstVisibleItemIndex == 0) -minOf(listState.firstVisibleItemScrollOffset, blankSpaceSize.roundToPx())
else -blankSpaceSize.roundToPx()
} else {
offsetMultiplier * 1000
}
} else {
0
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background)
) {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
if (!oneHandUI.value) {
Divider()
}
}
}
item {
if (searchText.value.text.isEmpty()) {
Spacer(Modifier.padding(bottom = 27.dp))
}
val actionButtonsOriginal = listOf(
Triple(
painterResource(MR.images.ic_add_link),
stringResource(MR.strings.add_contact_tab),
addContact,
),
Triple(
painterResource(MR.images.ic_qr_code),
if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link),
scanPaste,
),
Triple(
painterResource(MR.images.ic_group),
stringResource(MR.strings.create_group_button),
createGroup,
)
)
val actionButtons by remember(oneHandUI.value) {
derivedStateOf {
if (oneHandUI.value) actionButtonsOriginal.asReversed() else actionButtonsOriginal
}
}
if (searchText.value.text.isEmpty()) {
Row {
SectionView {
actionButtons.map {
NewChatButton(
icon = it.first,
text = it.second,
click = it.third,
)
}
}
}
if (deletedChats.isNotEmpty()) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
closeOnTop = !oneHandUI.value,
) {
DeletedContactsView(rh = rh, closeDeletedChats = closeDeletedChats, close = {
ModalManager.start.closeModals()
})
}
when (listState.firstVisibleItemIndex) {
0 -> 0
1 -> listState.firstVisibleItemScrollOffset
else -> 1000
}
}
) {
Icon(
painterResource(MR.images.ic_inventory_2),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(false)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
IntOffset(0, y)
}
// show background when something is scrolled because otherwise the bar is transparent.
// not using background always because of gradient in SimpleX theme
.background(
if (scrolledSomething && (keyboardState == KeyboardState.Opened || searchText.value.text.isNotEmpty())) {
MaterialTheme.colors.background
} else {
Color.Unspecified
}
)
) {
Divider()
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier))) {
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
}
}
}
item {
if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) {
if (!oneHandUI.value) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {}
} else {
item {
DeletedChatsItem(actionButtonsOriginal.asReversed())
}
item {
if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) {
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {}
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
}
}
}
item {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
item {
NoFilteredContactsItem()
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
if (appPlatform.isAndroid) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
}
}
@Composable
fun NonOneHandLazyColumn() {
val blankSpaceSize = topPaddingToContent()
LazyColumnWithScrollBar(
Modifier.imePadding(),
state = listState,
reverseLayout = false
) {
item {
Box(Modifier.padding(top = blankSpaceSize)) {
AppBarTitle(
stringResource(MR.strings.new_message),
hostDevice(rh?.remoteHostId),
bottomPadding = DEFAULT_PADDING
)
}
}
stickyHeader {
val scrolledSomething by remember { derivedStateOf { listState.firstVisibleItemScrollOffset > 0 || listState.firstVisibleItemIndex > 0 } }
Column(
Modifier
.zIndex(1f)
.offset {
val y = if (searchText.value.text.isNotEmpty() || (appPlatform.isAndroid && keyboardState == KeyboardState.Opened)) {
if (listState.firstVisibleItemIndex == 0) (listState.firstVisibleItemScrollOffset - (listState.layoutInfo.visibleItemsInfo[0].size - blankSpaceSize.roundToPx())).coerceAtLeast(0)
else blankSpaceSize.roundToPx()
} else {
when (listState.firstVisibleItemIndex) {
0 -> 0
1 -> -listState.firstVisibleItemScrollOffset
else -> -1000
}
}
IntOffset(0, y)
}
// show background when something is scrolled because otherwise the bar is transparent.
// not using background always because of gradient in SimpleX theme
.background(
if (scrolledSomething && (keyboardState == KeyboardState.Opened || searchText.value.text.isNotEmpty())) {
MaterialTheme.colors.background
} else {
Color.Unspecified
}
)
) {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Divider()
}
}
item {
DeletedChatsItem(actionButtonsOriginal)
}
item {
if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {}
}
}
item {
NoFilteredContactsItem()
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
}
if (appPlatform.isAndroid) {
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
}
Box {
if (oneHandUI.value) {
OneHandLazyColumn()
StatusBarBackground()
} else {
NonOneHandLazyColumn()
NavigationBarBackground(oneHandUI.value, true)
}
}
}
@@ -554,26 +628,7 @@ private fun contactTypesSearchTargets(baseContactTypes: List<ContactType>, searc
@Composable
private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats: () -> Unit, close: () -> Unit) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val keyboardState by getKeyboardState()
val showToolbarInOneHandUI = remember { derivedStateOf { keyboardState == KeyboardState.Closed && oneHandUI.value } }
Scaffold(
bottomBar = {
if (showToolbarInOneHandUI.value) {
Column {
Divider()
CloseSheetBar(
close = closeDeletedChats,
showClose = true,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) },
arrangement = Arrangement.Bottom,
closeBarTitle = generalGetString(MR.strings.deleted_chats),
barPaddingValues = PaddingValues(horizontal = 0.dp)
)
}
}
}
) { contentPadding ->
Box {
val listState = remember { appBarHandler.listState }
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
@@ -590,57 +645,93 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
contactChats = allChats
)
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
contentPadding = contentPadding,
reverseLayout = oneHandUI.value,
) {
item {
if (!oneHandUI.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
}
item {
if (!oneHandUI.value) {
Divider()
}
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Divider()
}
item {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary,
Box {
val topPaddingToContent = topPaddingToContent()
LazyColumnWithScrollBar(
if (!oneHandUI.value) Modifier.imePadding() else Modifier,
contentPadding = PaddingValues(
top = if (!oneHandUI.value) topPaddingToContent else 0.dp,
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
),
reverseLayout = oneHandUI.value,
) {
item {
if (!oneHandUI.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
}
}
item {
if (!oneHandUI.value) {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
} else {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
}
Divider()
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
item {
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary,
)
}
}
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = false)
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = false)
}
if (appPlatform.isAndroid) {
item {
Spacer(if (oneHandUI.value) Modifier.windowInsetsTopHeight(WindowInsets.statusBars) else Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(oneHandUI.value, true)
}
}
if (oneHandUI.value) {
Column(Modifier.align(Alignment.BottomCenter)) {
DefaultAppBar(
navigationButton = { NavigationButtonBack(onButtonClicked = closeDeletedChats) },
fixedTitleText = generalGetString(MR.strings.deleted_chats),
onTop = false,
)
}
}
}
@@ -29,10 +29,12 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@@ -398,8 +400,12 @@ fun ActiveProfilePicker(
.fillMaxSize()
.alpha(if (progressByTimeout) 0.6f else 1f)
) {
LazyColumnWithScrollBar(userScrollEnabled = !switchingProfile.value) {
LazyColumnWithScrollBar(Modifier.padding(top = topPaddingToContent()), userScrollEnabled = !switchingProfile.value) {
item {
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (oneHandUI.value) {
Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp))
}
AppBarTitle(stringResource(MR.strings.select_chat_profile), hostDevice(rhId), bottomPadding = DEFAULT_PADDING)
}
val activeProfile = filteredProfiles.firstOrNull { it.activeUser }
@@ -434,6 +440,9 @@ fun ActiveProfilePicker(
ProfilePickerUserOption(p)
}
}
item {
Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING))
}
}
}
if (progressByTimeout) {
@@ -472,13 +481,13 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection
end = 16.dp
),
click = {
ModalManager.start.showCustomModal { close ->
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
showSearch = true,
searchAlwaysVisible = true,
onSearchValueChanged = { search.value = it },
content = {
ActiveProfilePicker(
search = search,
@@ -616,6 +625,7 @@ fun LinkTextView(link: String, share: Boolean) {
enabled = false,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
})
}
@@ -76,15 +76,9 @@ private fun CreateSimpleXAddressLayout(
createAddress: () -> Unit,
nextStep: () -> Unit,
) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.themedBackground(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.simplex_address))
@@ -23,11 +23,7 @@ import dev.icerock.moko.resources.StringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
) {
ColumnWithScrollBar(Modifier.padding(DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.how_simplex_works), withPadding = false)
ReadableText(MR.strings.many_people_asked_how_can_it_deliver)
ReadableText(MR.strings.to_protect_privacy_simplex_has_ids_for_queues)
@@ -7,21 +7,16 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.themedBackground
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.remote.AddingMobileDevice
import chat.simplex.common.views.remote.DeviceNameField
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
@@ -59,34 +54,32 @@ private fun LinkAMobileLayout(
staleQrCode: MutableState<Boolean>,
updateDeviceName: (String) -> Unit,
) {
Column(Modifier.themedBackground()) {
CloseSheetBar(close = {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
})
BackHandler(onBack = {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
})
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) {
Column(
Modifier.weight(0.3f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) {
ChatModel.controller.appPrefs.offerRemoteMulticast.set(it)
ModalView({ appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }) {
Column(Modifier.fillMaxSize().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
}
Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) {
Column(
Modifier.weight(0.3f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) {
ChatModel.controller.appPrefs.offerRemoteMulticast.set(it)
}
}
}
}
Box(Modifier.weight(0.7f)) {
AddingMobileDevice(false, staleQrCode, connecting) {
// currentRemoteHost will be set instantly but remoteHosts may be delayed
if (chatModel.remoteHosts.isEmpty() && chatModel.currentRemoteHost.value == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
Box(Modifier.weight(0.7f)) {
AddingMobileDevice(false, staleQrCode, connecting) {
// currentRemoteHost will be set instantly but remoteHosts may be delayed
if (chatModel.remoteHosts.isEmpty() && chatModel.currentRemoteHost.value == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
}
}
}
}
@@ -25,16 +25,9 @@ import chat.simplex.res.MR
@Composable
fun SetNotificationsMode(m: ChatModel) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(
modifier = Modifier
.fillMaxSize()
.themedBackground()
) {
ColumnWithScrollBar(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.onboarding_notifications_mode_title))
}
@@ -104,13 +104,10 @@ private fun SetupDatabasePassphraseLayout(
onConfirmEncrypt: () -> Unit,
nextStep: () -> Unit,
) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(
Modifier.fillMaxSize().themedBackground().padding(bottom = DEFAULT_PADDING * 2),
Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer).padding(bottom = DEFAULT_PADDING * 2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.setup_database_passphrase))
@@ -31,15 +31,17 @@ import dev.icerock.moko.resources.StringResource
@Composable
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
if (onboarding) {
ModalView({}, showClose = false, endButtons = {
IconButton({ ModalManager.fullscreen.showModal { HowItWorks(chatModel.currentUser.value, null) }}) {
Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary)
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false, endButtons = {
IconButton({ ModalManager.fullscreen.showModal { HowItWorks(chatModel.currentUser.value, null) } }) {
Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary)
}
}) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = chatModel.controller.appPrefs.onboardingStage
)
}
}) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = chatModel.controller.appPrefs.onboardingStage
)
}
} else {
SimpleXInfoLayout(
@@ -56,7 +58,6 @@ fun SimpleXInfoLayout(
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -119,11 +119,10 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
ModalView(close = close) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING.times(0.75f))
) {
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), bottomPadding = DEFAULT_PADDING)
AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING)
v.features.forEach { feature ->
if (feature.show) {
@@ -74,9 +74,7 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) {
val sessionAddress = remember { mutableStateOf("") }
val remoteCtrls = remember { mutableStateListOf<RemoteCtrlInfo>() }
val session = remember { chatModel.remoteCtrlSession }.value
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
val discovery = if (session == null) null else session.sessionState is UIRemoteCtrlSessionState.Searching
if (discovery == true || (discovery == null && !showConnectScreen.value)) {
SearchingDesktop(deviceName, remoteCtrls)
@@ -408,9 +406,7 @@ private fun DesktopAddressView(sessionAddress: MutableState<String>) {
@Composable
private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.linked_desktops))
SectionView(stringResource(MR.strings.desktop_devices).uppercase()) {
remoteCtrls.forEach { rc ->
@@ -89,7 +89,7 @@ fun ConnectMobileLayout(
connectDesktop: () -> Unit,
deleteHost: (RemoteHostInfo) -> Unit,
) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
@@ -176,7 +176,15 @@ private fun ConnectMobileViewLayout(
refreshQrCode: () -> Unit = {},
UnderQrLayout: @Composable () -> Unit = {},
) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
@Composable
fun ScrollableLayout(content: @Composable ColumnScope.() -> Unit) {
if (LocalAppBarHandler.current != null) {
ColumnWithScrollBar(content = content)
} else {
ColumnWithScrollBarNoAppBar(content = content)
}
}
ScrollableLayout {
if (title != null) {
AppBarTitle(title)
}
@@ -202,10 +202,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (ModalData.() -> Unit) -> U
) {
val secondsLabel = stringResource(MR.strings.network_option_seconds_label)
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.network_settings_title))
if (currentRemoteHost == null) {
@@ -328,9 +325,7 @@ private fun SMPProxyModePicker(
icon = painterResource(MR.images.ic_settings_ethernet),
onSelected = {
showModal {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.network_smp_proxy_mode_private_routing))
SectionViewSelectableCards(null, smpProxyMode, values, updateSMPProxyMode)
}
@@ -365,9 +360,7 @@ private fun SMPProxyFallbackPicker(
enabled = enabled,
onSelected = {
showModal {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.network_smp_proxy_fallback_allow_downgrade))
SectionViewSelectableCards(null, smpProxyFallback, values, updateSMPProxyFallback)
}
@@ -4,9 +4,11 @@ import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
import SectionItemViewWithoutMinPadding
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.CircleShape
@@ -39,6 +41,7 @@ import chat.simplex.common.views.chat.item.msgTailWidthDp
import chat.simplex.res.MR
import com.godaddy.android.colorpicker.ClassicColorPicker
import com.godaddy.android.colorpicker.HsvColor
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@@ -86,27 +89,114 @@ object AppearanceScope {
}
@Composable
fun MessageShapeSection() {
SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase(), contentPadding = PaddingValues()) {
Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING + 4.dp ) ,verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(MR.strings.settings_message_shape_corner), color = colors.onBackground)
Spacer(Modifier.width(10.dp))
Slider(
remember { appPreferences.chatItemRoundness.state }.value,
valueRange = 0f..1f,
steps = 20,
onValueChange = {
val diff = it % 0.05f
appPreferences.chatItemRoundness.set(it + (if (diff >= 0.025f) -diff + 0.05f else -diff))
saveThemeToDatabase(null)
},
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
fun AppToolbarsSection() {
BoxWithConstraints {
SectionView(stringResource(MR.strings.appearance_app_toolbars).uppercase()) {
SectionItemViewWithoutMinPadding {
Box(Modifier.weight(1f)) {
Text(
stringResource(MR.strings.appearance_in_app_bars_alpha),
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha)
},
maxLines = 1
)
}
Spacer(Modifier.padding(end = 10.dp))
Slider(
(1 - remember { appPrefs.inAppBarsAlpha.state }.value).coerceIn(0f, 0.5f),
onValueChange = {
val diff = it % 0.025f
appPrefs.inAppBarsAlpha.set(1f - (String.format(Locale.US, "%.3f", it + (if (diff >= 0.0125f) -diff + 0.025f else -diff)).toFloatOrNull() ?: 1f))
},
Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f),
valueRange = 0f..0.5f,
steps = 21,
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
)
}
// In Android in OneHandUI there is a problem with setting initial value of blur if it was 0 before entering the screen.
// So doing in two steps works ok
fun saveBlur(value: Int) {
val oneHandUI = appPrefs.oneHandUI.get()
val pref = appPrefs.appearanceBarsBlurRadius
if (appPlatform.isAndroid && oneHandUI && pref.get() == 0) {
pref.set(if (value > 2) value - 1 else value + 1)
withApi {
delay(50)
pref.set(value)
}
} else {
pref.set(value)
}
}
val blur = remember { appPrefs.appearanceBarsBlurRadius.state }
if (appPrefs.deviceSupportsBlur || blur.value > 0) {
SectionItemViewWithoutMinPadding {
Box(Modifier.weight(1f)) {
Text(
stringResource(MR.strings.appearance_bars_blur_radius),
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
saveBlur(50)
},
maxLines = 1
)
}
Spacer(Modifier.padding(end = 10.dp))
Slider(
blur.value.toFloat() / 100f,
onValueChange = {
val diff = it % 0.05f
saveBlur(((String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f) * 100).toInt())
},
Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f),
valueRange = 0f..1f,
steps = 21,
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
}
}
}
}
}
@Composable
fun MessageShapeSection() {
BoxWithConstraints {
SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase()) {
SectionItemViewWithoutMinPadding {
Text(stringResource(MR.strings.settings_message_shape_corner), Modifier.weight(1f))
Spacer(Modifier.width(10.dp))
Slider(
remember { appPreferences.chatItemRoundness.state }.value,
onValueChange = {
val diff = it % 0.05f
appPreferences.chatItemRoundness.set(it + (if (diff >= 0.025f) -diff + 0.05f else -diff))
saveThemeToDatabase(null)
},
Modifier.widthIn(max = (this@BoxWithConstraints.maxWidth - DEFAULT_PADDING * 2) * 0.618f),
valueRange = 0f..1f,
steps = 20,
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
}
SettingsPreferenceItem(icon = null, stringResource(MR.strings.settings_message_shape_tail), appPreferences.chatItemTail)
}
SettingsPreferenceItem(icon = null, stringResource(MR.strings.settings_message_shape_tail), appPreferences.chatItemTail)
}
}
@@ -115,7 +205,7 @@ object AppearanceScope {
val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) }
SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(60.dp)
Box(Modifier.size(50.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22))
.clip(RoundedCornerShape(percent = 22))
.clickable {
@@ -129,7 +219,7 @@ object AppearanceScope {
Text("Aa", color = if (localFontScale.value == 1f) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground)
}
}
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(15.dp))
// Text("${(localFontScale.value * 100).roundToInt()}%", Modifier.width(70.dp), textAlign = TextAlign.Center, fontSize = 12.sp)
if (appPlatform.isAndroid) {
Slider(
@@ -185,7 +275,7 @@ object AppearanceScope {
Column(Modifier
.drawWithCache {
if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null) {
chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor)
chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, null, null)
} else {
onDrawBehind {
drawRect(themeBackgroundColor)
@@ -514,9 +604,7 @@ object AppearanceScope {
@Composable
fun CustomizeThemeView(onChooseType: (WallpaperType?) -> Unit) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
val currentTheme by CurrentColors.collectAsState()
AppBarTitle(stringResource(MR.strings.customize_theme_title))
@@ -909,10 +997,7 @@ object AppearanceScope {
currentColors: () -> ThemeManager.ActiveTheme,
onColorChange: (Color?) -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
) {
ColumnWithScrollBar(Modifier.imePadding()) {
AppBarTitle(name.text)
val supportedLiveChange = name in listOf(ThemeColor.SECONDARY, ThemeColor.BACKGROUND, ThemeColor.SURFACE, ThemeColor.RECEIVED_MESSAGE, ThemeColor.SENT_MESSAGE, ThemeColor.SENT_QUOTE, ThemeColor.WALLPAPER_BACKGROUND, ThemeColor.WALLPAPER_TINT)
@@ -36,7 +36,7 @@ fun CallSettingsLayout(
callOnLockScreen: SharedPreference<CallOnLockScreen>,
editIceServers: () -> Unit,
) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_calls))
val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) }
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
@@ -22,12 +22,10 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@Composable
fun DeveloperView(
m: ChatModel,
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
val m = chatModel
ColumnWithScrollBar {
val uriHandler = LocalUriHandler.current
AppBarTitle(stringResource(MR.strings.settings_developer_tools))
val developerTools = m.controller.appPrefs.developerTools
@@ -35,7 +33,7 @@ fun DeveloperView(
val unchangedHints = mutableStateOf(unchangedHintPreferences())
SectionView {
InstallTerminalAppItem(uriHandler)
ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(false, close) }) }
ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.start.showModalCloseable { TerminalView(false) } } }
ResetHintsItem(unchangedHints)
SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools)
SectionTextFooter(
@@ -21,11 +21,7 @@ fun HelpView(userDisplayName: String) {
@Composable
fun HelpLayout(userDisplayName: String) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
){
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)){
AppBarTitle(String.format(stringResource(MR.strings.personal_welcome), userDisplayName), withPadding = false)
ChatHelpView()
}
@@ -56,10 +56,7 @@ private fun HiddenProfileLayout(
user: User,
saveProfilePassword: (String) -> Unit
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.hide_profile))
SectionView(contentPadding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
UserProfileRow(user)
@@ -109,7 +109,7 @@ fun NetworkAndServersView() {
toggleSocksProxy: (Boolean) -> Unit,
) {
val m = chatModel
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }}
@@ -304,10 +304,7 @@ fun SocksProxySettings(
}
},
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
) {
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings))
SectionView(stringResource(MR.strings.network_socks_proxy).uppercase()) {
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
@@ -479,9 +476,7 @@ fun SessionModePicker(
icon = painterResource(MR.images.ic_safety_divider),
onSelected = {
showModal {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation))
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
}
@@ -56,9 +56,7 @@ fun NotificationsSettingsLayout(
val modes = remember { notificationModes() }
val previewModes = remember { notificationPreviewModes() }
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.notifications))
SectionView(null) {
if (appPlatform == AppPlatform.ANDROID) {
@@ -90,9 +88,7 @@ fun NotificationsModeView(
onNotificationsModeSelected: (NotificationsMode) -> Unit,
) {
val modes = remember { notificationModes() }
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.settings_notifications_mode_title).lowercase().capitalize(Locale.current))
SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected)
}
@@ -104,9 +100,7 @@ fun NotificationPreviewView(
onNotificationPreviewModeSelected: (NotificationPreviewMode) -> Unit,
) {
val previewModes = remember { notificationPreviewModes() }
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.settings_notification_preview_title))
SectionViewSelectable(null, notificationPreviewMode, previewModes, onNotificationPreviewModeSelected)
}
@@ -66,9 +66,7 @@ private fun PreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) }
TimedMessagesFeatureSection(timedMessages) {
@@ -55,9 +55,7 @@ fun PrivacySettingsView(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
setPerformLA: (Boolean) -> Unit
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
AppBarTitle(stringResource(MR.strings.your_privacy))
PrivacyDeviceSection(showSettingsModal, setPerformLA)
@@ -514,9 +512,7 @@ fun SimplexLockView(
}
}
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.chat_lock))
SectionView {
EnableLock(remember { appPrefs.performLA.state }) { performLAToggle ->
@@ -75,10 +75,7 @@ private fun ProtocolServerLayout(
onUpdate: (ServerCfg) -> Unit,
onDelete: () -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server))
if (server.preset) {

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