Compare commits
12 Commits
c01372353c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 680669df00 | |||
| d13f35e045 | |||
| 9fce19dab2 | |||
| c0a4c71766 | |||
| d65be8a1ef | |||
| 9f8877a741 | |||
| 3f681feb26 | |||
| a31a88f87a | |||
| 9382a1f946 | |||
| 6f32eb8972 | |||
| 058926cf6f | |||
| 7534ecf7d1 |
295
README.md
@@ -5,133 +5,280 @@ SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
[TatuChat](https://tatuchat.weltonmoura.com.br/home/) is an open source, nonprofit and cute [[matrix](https://matrix.org)] client written in [Flutter](https://flutter.dev). The goal of the app is to create an easy to use instant messenger which is open source and accessible for everyone.
|
||||
# TatuChat
|
||||
|
||||
### Links:
|
||||
<p align="center">
|
||||
<img src="https://tatuchat.weltonmoura.com.br/home/assets/logo.png" height="180" alt="TatuChat">
|
||||
</p>
|
||||
|
||||
- 🌐 [[Weblate] Translate TatuChat into your language](https://hosted.weblate.org/projects/fluffychat/)
|
||||
- 🌍 [[m] Join the community](https://matrix.to/#/#fluffy-space:matrix.org)
|
||||
- 📰 [[Mastodon] Get updates on social media](https://troet.cafe/@krille)
|
||||
- 💝 [[Liberapay] Support TatuChat development](https://de.liberapay.com/KrilleChritzelius)
|
||||
<p align="center">
|
||||
Cliente de mensagens instantâneas <strong>open source</strong>, <strong>sem fins lucrativos</strong> e
|
||||
multiplataforma, construído sobre o protocolo
|
||||
<a href="https://matrix.org">Matrix</a> com <a href="https://flutter.dev">Flutter</a>.
|
||||
TatuChat é um <em>fork</em> do acclaimed
|
||||
<a href="https://fluffy.chat">FluffyChat</a>, com foco em privacidade, simplicidade e
|
||||
código aberto.
|
||||
</p>
|
||||
|
||||
<a href='https://ko-fi.com/krille' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi5.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
|
||||
> 🌐 **Site, downloads e novidades:** <https://tatuchat.weltonmoura.com.br/home/>
|
||||
|
||||
### Screenshots:
|
||||
> ⚠️ **Status do projeto: alfa.** O aplicativo está em desenvolvimento ativo e ainda pode
|
||||
> apresentar erros, perder dados ou mudar de comportamento entre versões. Use com cuidado e
|
||||
> reporte problemas para ajudar a tornar o TatuChat mais estável.
|
||||
|
||||
<img src="https://tatuchat.weltonmoura.com.br/home/assets/logo.png" height="300">
|
||||
## O que é o TatuChat
|
||||
|
||||
# Features
|
||||
Um mensageiro federado e descentralizado: nenhuma empresa no meio, nenhum servidor único. Você
|
||||
escolhe o *homeserver* que quiser (ou usa um dos recomendados), conversa com quem está em outros
|
||||
servidores e continua sendo dono dos seus dados. Tudo com criptografia ponta a ponta
|
||||
(Olm/Megolm via [Vodozemac](https://github.com/matrix-org/vodozemac) em WebAssembly).
|
||||
|
||||
- 📩 Send all kinds of messages, images and files
|
||||
- 🤙 Video calls with Matrix RTC
|
||||
- 🎙️ Voice messages
|
||||
- 📍 Location sharing
|
||||
- 🔔 Push notifications
|
||||
- 💬 Unlimited private and public group chats
|
||||
- 📣 Public channels with thousands of participants
|
||||
- 🛠️ Feature rich group moderation including all matrix features
|
||||
- 🔍 Discover and join public groups
|
||||
- 🎨 Material You design
|
||||
- 😄 Custom emotes and stickers
|
||||
- 🌌 Spaces
|
||||
- 🔐 End to end encryption
|
||||
- 🔒 Encrypted chat backup
|
||||
- 😀 Emoji verification & cross signing
|
||||
... and much more.
|
||||
- 🔐 Criptografia ponta a ponta, com verificação por emoji e *cross-signing*
|
||||
- 💾 Backup de conversas criptografado
|
||||
- 📩 Texto, imagens, áudio, vídeo, arquivos, stickers, emotes e localização
|
||||
- 🤙 Videochamadas pelo Matrix RTC (LiveKit)
|
||||
- 🎙️ Mensagens de voz
|
||||
- 🔔 Notificações push, com ou sem Google (UnifiedPush / ntfy)
|
||||
- 🌌 Espaços para navegar hierarquias inteiras de salas
|
||||
- 🎨 Design Material You, com cores dinâmicas do Android
|
||||
|
||||
## 🚀 Inovações do TatuChat
|
||||
|
||||
# Installation
|
||||
O TatuChat reescreveu a forma de navegar e de consumir conteúdo dentro do Matrix. As seções
|
||||
abaixo descrevem os módulos desenvolvidos sobre o FluffyChat.
|
||||
|
||||
Please visit the website for installation instructions:
|
||||
### Abas e barra lateral
|
||||
|
||||
- https://tatuchat.weltonmoura.com.br/home/
|
||||
O app não usa mais a *navigation rail* do FluffyChat. A navegação principal virou uma
|
||||
**barra lateral** (desktop e tablet) com as entradas *Criar grupo*, *Definir status*,
|
||||
*Convidar contato*, *Arquivo*, **Apps** e *Configurações*, além do seletor de contas.
|
||||
|
||||
# Configuration and Mobile Device Management (MDM)
|
||||
A tela inicial foi dividida em **quatro abas**, com barra superior em telas largas e barra
|
||||
inferior em telas compactas:
|
||||
|
||||
TatuChat supports configuration via MDM on Android&iOS (since v2.10.0) and via a config.json file on web. You can see the populated configuration for MDM on Android in this file under `/android/app/src/main/res/xml/app_restrictions.xml`.
|
||||
An example configuration can be found in the `config.sample.json` file.
|
||||
| Aba | O que mostra |
|
||||
| --- | --- |
|
||||
| **Conversas** | Apenas conversas normais |
|
||||
| **Canais** | Apenas canais |
|
||||
| **Espaços** | Os espaços em que você entrou, como tela inicial |
|
||||
| **Murais** | Apenas murais |
|
||||
|
||||
# How to build
|
||||
Canais e murais **não aparecem** na lista de conversas, e vice-versa: cada tipo tem sua própria
|
||||
aba, com busca própria — e os murais mostram ainda o número de inscritos de cada um.
|
||||
|
||||
1. To build TatuChat you need [Flutter](https://flutter.dev) and [Rust](https://www.rust-lang.org/tools/install)
|
||||
### 📣 Canais
|
||||
|
||||
2. Clone the repo:
|
||||
```
|
||||
git clone https://gitea.weltonmoura.com.br/welton/tatuchat.git
|
||||
cd tatuchat
|
||||
```
|
||||
3. Choose your target platform below and enable support for it.
|
||||
3.1 If you want, enable Googles Firebase Cloud Messaging:
|
||||
Um canal é uma sala pública de distribuição, no estilo de um feed, não de um chat. O tipo é
|
||||
gravado no estado compartilhado da sala (`tatuchat.room_type = "canal"`), com uma tag pessoal
|
||||
(`tatuchat_canal`) para identificação local.
|
||||
|
||||
`./scripts/add-firebase-messaging.sh`
|
||||
A sala usa a página de chat normal, mas em **modo feed**:
|
||||
|
||||
4. Debug with: `flutter run`
|
||||
- 🗞️ Publicações viram **cartões de largura total**, em vez de balões
|
||||
- ✍️ O nome do autor aparece **sempre**, inclusive nas suas próprias publicações, para que o
|
||||
feed nunca pareça anônimo
|
||||
- ⬅️ Tudo alinhado à esquerda, com balões totalmente arredondados
|
||||
- 🖼️ Imagens ocupam a largura total, mantendo a proporção original
|
||||
- 🔐 **Somente administradores publicam no nível principal**; dentro das threads, qualquer
|
||||
participante comenta
|
||||
- 💬 Cada publicação abre uma **thread de comentários**, com reações, respostas, edição,
|
||||
exclusão e denúncia
|
||||
|
||||
### 🖼️ Murais
|
||||
|
||||
Um mural é uma página dedicada — não uma variante da timeline. O tipo é gravado em
|
||||
`tatuchat.room_type = "mural"` (tag pessoal `tatuchat_mural`), e abrir o mural **nunca** mostra
|
||||
a lista de mensagens: mostra um **grid visual**.
|
||||
|
||||
- 🟦 Grade responsiva de 3 colunas alimentada apenas por **imagens, vídeos e stickers**
|
||||
- 🎨 Cabeçalho com capa, avatar, nome, descrição, autor e número de inscritos
|
||||
- 👆 **Toque simples** abre o visualizador do post, com painel de comentários e campo de
|
||||
resposta
|
||||
- 🤏 **Toque longo** abre a imagem em tela cheia, com *pinch-to-zoom* e deslize entre as mídias
|
||||
do mural
|
||||
- 💬 Comentários são **threads Matrix reais**, com árvore aninhada, reações, resposta,
|
||||
exclusão do próprio comentário e denúncia
|
||||
- 🧹 A timeline carrega em blocos conforme você desce, para não travar em murais grandes
|
||||
|
||||
### 🌌 Espaços
|
||||
|
||||
Matrix costuma tratar espaços como um recurso de segunda classe. No TatuChat eles viraram uma
|
||||
**tela inicial** completa:
|
||||
|
||||
- 🧭 A aba **Espaços** é a porta de entrada: explorer a hierarquia inteira de uma conta sem sair
|
||||
do app
|
||||
- 🔎 Busca e filtro dentro do espaço, com capa do espaço e destaque da sala ativa
|
||||
- ➕ Ingresso rápido em salas filhas públicas, direto da hierarquia
|
||||
- 🛠️ Edição das salas filhas a partir do próprio explorador
|
||||
- ↔️ A sala ativa é carregada na coluna lateral e preservada entre navegações
|
||||
|
||||
### 🧩 Mini Apps
|
||||
|
||||
O TatuChat é **extensível pela comunidade**: qualquer pessoa pode escrever um mini app e
|
||||
instalá-lo no próprio app, sem loja, sem build e sem publishing.
|
||||
|
||||
Um mini app é **um único arquivo `.html`**, executado em um *web view* isolado:
|
||||
|
||||
- 📥 Instalação **por arquivo** ou **por URL**
|
||||
- 🔐 Cada app tem seu próprio armazenamento chave-valor, persistido no *account data* do Matrix
|
||||
(ou seja, o app sincroniza entre os seus dispositivos)
|
||||
- 🎨 A ponte expõe a paleta e o brilho do tema atual, para o app acompanhar o Material You
|
||||
- 👤 A ponte expõe o perfil do usuário (id, nome, avatar e homeserver)
|
||||
- 📦 Dois mini apps já vêm embutidos: **`tasks`** (lista de tarefas) e **`accountdata`**
|
||||
(editor de *account data*)
|
||||
|
||||
As ações disponíveis na ponte são:
|
||||
|
||||
| Ação | O que faz |
|
||||
| --- | --- |
|
||||
| `io.tatuchat.storage.load` | Lê o armazenamento do app |
|
||||
| `io.tatuchat.storage.save` | Sobrescreve o armazenamento do app |
|
||||
| `io.tatuchat.identity.get` | Perfil do usuário + avatar como data URI |
|
||||
| `io.tatuchat.theme.get` | Paleta e brilho do tema em hex CSS |
|
||||
| `io.tatuchat.accountdata.list` | Lista todas as chaves de *account data* |
|
||||
| `io.tatuchat.accountdata.get` | Lê uma chave de *account data* |
|
||||
| `io.tatuchat.accountdata.set` | Escreve uma chave de *account data* |
|
||||
|
||||
**📖 Documentação completa:** [`docs/miniapps.md`](docs/miniapps.md) — manifesto em `<meta>`,
|
||||
protocolo da ponte, exemplo de HTML e limitações.
|
||||
|
||||
> 🔐 **Segurança — leia antes de instalar apps de terceiros.** As ações de `accountdata.*` não
|
||||
> são restritas por app: um mini app pode ler e escrever qualquer chave de *account data* da
|
||||
> conta, incluindo as chaves de outros mini apps. Não existe modelo de permissões nem pedido de
|
||||
> consentimento. Instale apenas apps cujo código você auditou. O app **não** expõe salas,
|
||||
> mensagens, arquivos nem envio de mensagens pela ponte.
|
||||
|
||||
### Outros detalhes
|
||||
|
||||
- 🎨 **Identidade visual própria:** cor primária esmeralda `#00D084`, paleta de sementes
|
||||
customizada, ícone, splash e ícone de notificação próprios
|
||||
- ⏱️ **Status personalizado aparece na hora:** a atualização de status é aplicada no cache local
|
||||
imediatamente, sem esperar o *sync* do homeserver
|
||||
- 🖼️ **Papel de parede e tema sincronizados:** via *account data* da conta Matrix, o tema segue
|
||||
entre dispositivos; há também a opção de suavizar a cor primária
|
||||
- 🔗 Deep links próprios (`im.tatuchat://chat/`) e convites com `client=im.tatuchat`
|
||||
|
||||
## 📲 Instalação
|
||||
|
||||
Downloads para Android, iOS, Linux, Windows, macOS e Web (PWA), além de notas de versão:
|
||||
|
||||
- <https://tatuchat.weltonmoura.com.br/home/>
|
||||
|
||||
## ⚙️ Configuração e MDM
|
||||
|
||||
O TatuChat aceita configuração gerenciada em Android e iOS (via MDM) e por um arquivo
|
||||
`config.json` na versão web.
|
||||
|
||||
- **Android:** as restrições declaradas ficam em
|
||||
`android/app/src/main/res/xml/app_restrictions.xml`, com as chaves `defaultHomeserver`,
|
||||
`presetHomeserver` e `welcomeText`. O restante das chaves é lido de `ManagedConfigurations`.
|
||||
- **Web:** sirva um `config.json` no mesmo caminho do app. O exemplo completo está em
|
||||
[`config.sample.json`](config.sample.json).
|
||||
|
||||
⚠️ Passe **somente os valores que você realmente precisa alterar**. Todas as chaves são
|
||||
opcionais; por exemplo, para trocar o homeserver padrão, altere apenas `defaultHomeserver`.
|
||||
|
||||
## 🛠️ Como compilar
|
||||
|
||||
1. Para compilar o TatuChat você precisa do [Flutter](https://flutter.dev) (3.47.3, Dart
|
||||
`>=3.11.1 <4.0.0`); para a versão web, também do [Rust](https://www.rust-lang.org/tools/install),
|
||||
usado para compilar o Vodozemac.
|
||||
|
||||
2. Clone o repositório:
|
||||
```bash
|
||||
git clone https://gitea.weltonmoura.com.br/welton/tatuchat.git
|
||||
cd tatuchat
|
||||
```
|
||||
|
||||
3. Escolha a plataforma alvo abaixo e habilite o suporte a ela.
|
||||
|
||||
4. Opcionalmente, habilite o Google Firebase Cloud Messaging:
|
||||
```bash
|
||||
./scripts/add-firebase-messaging.sh
|
||||
```
|
||||
|
||||
5. Debug com `flutter run`.
|
||||
|
||||
### Android
|
||||
|
||||
* Build with: `flutter build apk`
|
||||
```bash
|
||||
flutter build apk --release
|
||||
# ou, por arquitetura (APKs bem menores):
|
||||
flutter build apk --release --split-per-abi
|
||||
```
|
||||
|
||||
Para assinar, preencha `android/key.properties` (já ignorado pelo Git) com `storeFile`,
|
||||
`keyAlias`, `storePassword` e `keyPassword`; o script `prepare-android-release.sh` faz isso a
|
||||
partir do segredo `$FDROID_KEY` em CI.
|
||||
|
||||
### iOS / iPadOS
|
||||
|
||||
* Have a Mac with Xcode installed, and set up for Xcode-managed app signing
|
||||
* If you want automatic app installation to connected devices, make sure you have Apple Configurator installed, with the Automation Tools (`cfgutil`) enabled
|
||||
* Set a few environment variables
|
||||
* FLUFFYCHAT_NEW_TEAM: the Apple Developer team that your certificates should live under
|
||||
* FLUFFYCHAT_NEW_GROUP: the group you want App IDs and such to live under (ie: com.example.fluffychat)
|
||||
* FLUFFYCHAT_INSTALL_IPA: set to `1` if you want the IPA to be deployed to connected devices after building, otherwise unset
|
||||
* Run `./scripts/build-ios.sh`
|
||||
* Um Mac com Xcode instalado, configurado para o *app signing* gerenciado pelo Xcode
|
||||
* Se quiser instalar automaticamente nos dispositivos conectados, tenha o Apple Configurator com
|
||||
as ferramentas de automação (`cfgutil`) habilitadas
|
||||
* Defina algumas variáveis de ambiente:
|
||||
* `FLUFFYCHAT_NEW_TEAM`: o time de Apple Developer que deve emitir os certificados
|
||||
* `FLUFFYCHAT_NEW_GROUP`: o grupo onde os App IDs vivem (ex.: `com.example.tatuchat`)
|
||||
* `FLUFFYCHAT_INSTALL_IPA`: defina como `1` para fazer o *deploy* do IPA nos dispositivos
|
||||
conectados após o build
|
||||
* Execute `./scripts/build-ios.sh`
|
||||
|
||||
### Web
|
||||
|
||||
* Build with:
|
||||
```bash
|
||||
./scripts/prepare-web.sh # To install Vodozemac
|
||||
./scripts/prepare-web.sh # compila o Vodozemac para WebAssembly
|
||||
flutter build web --release
|
||||
```
|
||||
|
||||
* Optionally configure by serving a `config.json` at the same path as fluffychat.
|
||||
An example can be found at `config.sample.json`. All values there are optional.
|
||||
**Please only the values, you really need**. If you e.g. only want
|
||||
to change the default homeserver, then only modify the `defaultHomeserver` key.
|
||||
|
||||
### Desktop (Linux, Windows, macOS)
|
||||
|
||||
* Enable Desktop support in Flutter: https://flutter.dev/desktop
|
||||
Habilite o suporte a desktop do Flutter: <https://flutter.dev/desktop>
|
||||
|
||||
#### Install custom dependencies (Linux)
|
||||
Dependências no Linux:
|
||||
|
||||
```bash
|
||||
sudo apt install libjsoncpp1 libsecret-1-dev libsecret-1-0 librhash0 libwebkit2gtk-4.0-dev lld
|
||||
```
|
||||
|
||||
* Build with one of these:
|
||||
```bash
|
||||
flutter build linux --release
|
||||
flutter build windows --release
|
||||
flutter build macos --release
|
||||
```
|
||||
|
||||
## How to run integration tests
|
||||
## 🧪 Testes de integração
|
||||
|
||||
You need to have docker installed locally! Run the preparation script before every test run:
|
||||
Os testes completos precisam de Docker (Synapse) rodando localmente. Antes de cada execução:
|
||||
|
||||
```sh
|
||||
```bash
|
||||
./scripts/prepare_integration_test.sh
|
||||
```
|
||||
|
||||
Then run all tests with:
|
||||
|
||||
```sh
|
||||
flutter test integration_test/mobile_test.dart
|
||||
```
|
||||
|
||||
## 📜 Licença
|
||||
|
||||
# Special thanks
|
||||
TatuChat é software livre, licença **AGPL-3.0-or-later**. Ele é derivado do FluffyChat, também
|
||||
AGPL-3.0-or-later, Copyright de Christian Kußowski e colaboradores. O arquivo `LICENSES/` traz as
|
||||
licenças de todas as dependências (REUSE).
|
||||
|
||||
* <a href="https://github.com/fabiyamada">Fabiyamada</a> is a graphics designer and has made the tatuchat logo and the banner. Big thanks for her great designs.
|
||||
## 🔗 Links
|
||||
|
||||
* Also thanks to all translators and testers! With your help, tatuchat is now available in more than 12 languages.
|
||||
- 🌐 [Site do TatuChat](https://tatuchat.weltonmoura.com.br/home/)
|
||||
- 💻 [Código-fonte](https://gitea.weltonmoura.com.br/welton/tatuchat)
|
||||
- 🐞 [Issues e bugs](https://gitea.weltonmoura.com.br/welton/tatuchat/issues)
|
||||
- 🧩 [Documentação dos Mini Apps](docs/miniapps.md)
|
||||
- 🌍 [[Weblate] Traduza o TatuChat](https://hosted.weblate.org/projects/fluffychat/)
|
||||
- 🦊 [Código do FluffyChat, o projeto original](https://github.com/krille-chan/fluffy-chat)
|
||||
- 📄 [Política de privacidade](PRIVACY.md) · [Segurança](SECURITY.md) · [Contribuindo](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
||||
|
||||
* The Matrix Foundation for making and maintaining the [emoji translations](https://github.com/matrix-org/matrix-spec/blob/main/data-definitions/sas-emoji.json) used for emoji verification, licensed Apache 2.0
|
||||
## 🙏 Agradecimentos
|
||||
|
||||
* Special thanks to MTRNord, Sorunome and Advocatux.
|
||||
* **[Fabiyamada](https://github.com/fabiyamada)** é designer gráfico e criou o logo e o banner do
|
||||
TatuChat. Muito obrigado pelos belos designs.
|
||||
* A todos os tradutores e testadores! Com a ajuda de vocês o TatuChat já está disponível em mais
|
||||
de 12 idiomas.
|
||||
* À **Matrix Foundation**, por manter e licenciar (Apache 2.0) as
|
||||
[traduções de emoji](https://github.com/matrix-org/matrix-spec/blob/main/data-definitions/sas-emoji.json)
|
||||
usadas na verificação por emoji.
|
||||
* A **MTRNord**, **Sorunome** e **Advocatux**.
|
||||
* Ao **FluffyChat** e a toda a comunidade Matrix — o TatuChat existe graças a esse trabalho.
|
||||
|
||||
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 790 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 74 KiB |
@@ -1,29 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.33822063"
|
||||
android:scaleY="0.33822063"
|
||||
android:translateX="30.24"
|
||||
android:translateY="31.24">
|
||||
<path
|
||||
android:pathData="M130.9,74.3c1.5,-0.3 2.8,-1 3.8,-2 4,-5.3 0.8,-11.8 -4.5,-12.6 -0.8,0 -1.5,-0.8 -1.5,-1.5v-0.5c0.8,-0.8 1.5,-1.8 2.5,-3.3 8.1,-10.8 11.8,-50.6 3.8,-53.7 -9.8,-3.3 -29.7,6.3 -38.3,17.4 -0.5,-0.3 -1,-1 -1,-1.8a7,7 0,0 0,-3.5 -6.8,7.8 7.8,0 0,0 -10.6,3.3c-0.5,0.8 -1.3,1.3 -2,1 -0.8,0 -1.5,-0.8 -1.5,-1.5 -0.5,-2.5 -2,-4.5 -4.3,-5.5 -4.8,-2 -9.8,0.8 -10.6,5.3 -0.3,0.8 -0.8,1.5 -1.5,1.5 -0.8,0.3 -1.5,-0.3 -2,-1a8,8 0,0 0,-6.5 -3.8,8 8,0 0,0 -7.8,7.3v0.6c0,0.8 -0.5,1.5 -1,1.8h-0.3c-8.3,-10.8 -28.5,-20.7 -38.5,-17.4 -8.1,2.8 -4.3,42.6 4,53.4 1.5,2 2.8,3.5 3.8,4.5 -0.3,0.8 -1,1.5 -1.8,1.5 -1.3,0 -2.5,0.5 -3.5,1.3a7.7,7.7 0,0 0,3 13.4c0.8,0.3 1.5,1 1.5,1.8s-0.5,1.8 -1.3,2c-1,0.5 -2,1 -2.8,2 -4,5.8 0,12.3 5.5,12.3a2,2 0,0 1,1.8 1.3c0.3,0.8 0.3,1.5 -0.5,2a6.5,6.5 0,0 0,-2 5.5c0.3,2.8 2,5.3 4.8,6.5 1.5,0.8 3,0.8 4.5,0.5 0.8,-0.3 1.5,0 2,0.8 0.5,0.5 0.5,1.5 0.3,2 -0.8,1.5 -1,3.3 -0.5,5a7.7,7.7 0,0 0,5.5 5.5c2.5,0.5 4.3,-0.3 5.5,-0.8 0.5,-0.3 -3.3,9.1 -6,15.4 -0.8,2 1.3,4.3 3.5,3.3 8.3,-3.8 22.2,-10.3 22.2,-9.8 0.5,5.3 6.5,9.1 12.3,5.3 1.3,-0.8 2,-2.3 2.3,-3.5 0.3,-0.8 1,-1.5 2,-1.5s1.8,0.5 2,1.5c0.3,1.3 0.8,2.3 1.8,3 5.8,4.5 12.3,0.8 12.8,-4.8a2,2 0,0 1,1.3 -1.8c0.8,-0.3 1.5,0 2,0.5a7.6,7.6 0,0 0,5.3 2.5c2.5,0 5,-1.3 6.5,-3.8 1,-1.5 1.3,-3 1,-5 0,-0.8 0.3,-1.5 0.8,-2s1.5,-0.5 2,0c1.5,0.8 3.3,1.3 5,0.8 2.8,-0.5 5,-2.8 5.8,-5.3 0.5,-1.8 0.3,-3.5 -0.5,-5.3 -0.3,-0.8 -0.3,-1.5 0.3,-2s1.3,-0.8 2,-0.8c1.8,0.3 3.3,0.3 4.8,-0.5 2.3,-1 3.8,-3 4.3,-5.5a7,7 0,0 0,-2 -6.5c-0.5,-0.5 -0.8,-1.3 -0.5,-2 0.3,-0.7 1,-1.3 1.8,-1.3 1.8,0 3.8,-0.5 5,-2a7.6,7.6 0,0 0,-2.5 -12.6c-0.8,-0.3 -1.3,-1 -1.3,-2 0,-0.9 0.7,-1.6 1.5,-1.6z"
|
||||
android:fillColor="#4d3f92"/>
|
||||
<path
|
||||
android:pathData="M110.7,21.4a7,7 0,0 1,0 4.5c-0.3,0.8 0,1.5 0.5,2s1.3,0.8 2,0.5c1,-0.5 2,-0.5 3,-0.5a7,7 0,0 1,5.8 3c1,1.3 1.8,3 1.5,4.8a7,7 0,0 1,-1.3 4c-0.5,0.5 -0.5,1.5 0,2 0.3,0.3 0.5,0.8 1,0.8 1,-0.3 2,-1 2.8,-2 4.5,-6.3 5.3,-26.2 0.8,-27.7s-12.3,1.5 -17.9,6c1.1,0.5 1.6,1.3 1.8,2.6z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M18.3,42.8l0.8,-0.8c0.5,-0.8 0.3,-1.5 0,-2a7.6,7.6 0,0 1,-1.3 -5.8c0.5,-2.8 2.8,-5 5.5,-5.8 1.5,-0.5 3,-0.3 4.5,0.3 0.8,0.3 1.5,0 2,-0.5s0.8,-1.3 0.5,-2a7,7 0,0 1,0 -4.5c0.3,-1 0.8,-2 1.5,-2.8 -5.5,-4.5 -13.9,-7.8 -18.4,-6.3 -4.5,1.5 -3.7,21.4 0.9,27.7 1,1.5 2.2,2.2 4,2.5z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M40.2,73.8m-9.3,0a9.3,9.3 0,1 1,18.6 0a9.3,9.3 0,1 1,-18.6 0"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M80,73.8a9.3,9.3 0,1 1,-18.6 0c0,-5 18.6,-5.3 18.6,0z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M100.9,73.8m-9.3,0a9.3,9.3 0,1 1,18.6 0a9.3,9.3 0,1 1,-18.6 0"
|
||||
android:fillColor="#fff"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -17,7 +17,7 @@ abstract class AppConfig {
|
||||
static const String inviteLinkPrefix = 'https://matrix.to/#/';
|
||||
static const String deepLinkPrefix = 'im.tatuchat://chat/';
|
||||
static const String schemePrefix = 'matrix:';
|
||||
static const String pushNotificationsChannelId = 'tatuchat_push';
|
||||
static const String pushNotificationsChannelId = 'tatuchat_push_v2';
|
||||
static const String pushNotificationsAppId = 'chat.fluffy.tatuchat';
|
||||
static const double borderRadius = 18.0;
|
||||
static const double spaceBorderRadius = 11.0;
|
||||
|
||||
@@ -59,6 +59,11 @@ enum AppSettings<T> {
|
||||
),
|
||||
// colorSchemeSeed stored as ARGB int
|
||||
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084),
|
||||
|
||||
/// When false the chosen seed color is used verbatim as the theme primary
|
||||
/// color. When true the Material 3 tonal palette is used instead, which
|
||||
/// softens it.
|
||||
softenThemeColor<bool>('chat.fluffy.soften_theme_color', false),
|
||||
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
|
||||
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
|
||||
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),
|
||||
|
||||
@@ -97,7 +97,14 @@ abstract class TatuPalette {
|
||||
/// When a [seed] is provided (e.g. a color chosen by the user or a system
|
||||
/// dynamic color), its tonal values are used for the primary family while
|
||||
/// the brand surfaces, accent and error colors are preserved.
|
||||
static ColorScheme scheme(Brightness brightness, [Color? seed]) {
|
||||
///
|
||||
/// Unless [softenPrimary] is set, the seed is used verbatim as the primary
|
||||
/// color, because the Material 3 tonal palette noticeably dulls it down.
|
||||
static ColorScheme scheme(
|
||||
Brightness brightness, [
|
||||
Color? seed,
|
||||
bool softenPrimary = false,
|
||||
]) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final p = isDark ? _dark : _light;
|
||||
final base = ColorScheme.fromSeed(
|
||||
@@ -106,12 +113,17 @@ abstract class TatuPalette {
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
|
||||
);
|
||||
final useBrandPrimary = seed == null;
|
||||
// Only force the exact color when the caller did not ask for the soft
|
||||
// Material 3 palette. `null` here means "keep the value from [base]".
|
||||
final exactPrimary = !useBrandPrimary && !softenPrimary ? seed : null;
|
||||
return base.copyWith(
|
||||
primary: useBrandPrimary ? p.primary : null,
|
||||
onPrimary: useBrandPrimary ? p.onPrimary : null,
|
||||
primary: useBrandPrimary ? p.primary : exactPrimary,
|
||||
onPrimary: useBrandPrimary
|
||||
? p.onPrimary
|
||||
: (exactPrimary == null ? null : _contrastOn(exactPrimary)),
|
||||
primaryContainer: useBrandPrimary ? p.primaryContainer : null,
|
||||
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
|
||||
surfaceTint: useBrandPrimary ? p.primary : null,
|
||||
surfaceTint: useBrandPrimary ? p.primary : exactPrimary,
|
||||
secondary: p.secondary,
|
||||
onSecondary: p.onSecondary,
|
||||
secondaryContainer: p.secondaryContainer,
|
||||
@@ -141,6 +153,12 @@ abstract class TatuPalette {
|
||||
surfaceBright: p.surfaceBright,
|
||||
);
|
||||
}
|
||||
|
||||
/// Picks the readable foreground for an arbitrary background color.
|
||||
static Color _contrastOn(Color background) =>
|
||||
ThemeData.estimateBrightnessForColor(background) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
}
|
||||
|
||||
class _Scheme {
|
||||
@@ -209,4 +227,4 @@ class _Scheme {
|
||||
final Color surfaceHighest;
|
||||
final Color surfaceDim;
|
||||
final Color surfaceBright;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ abstract class FluffyThemes {
|
||||
BuildContext context,
|
||||
Brightness brightness, [
|
||||
Color? seed,
|
||||
bool softenPrimary = false,
|
||||
]) {
|
||||
final colorScheme = TatuPalette.scheme(brightness, seed);
|
||||
final colorScheme = TatuPalette.scheme(brightness, seed, softenPrimary);
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
final dividerColor = brightness == Brightness.dark
|
||||
? colorScheme.surfaceContainerHighest
|
||||
|
||||
@@ -3029,5 +3029,15 @@
|
||||
"@uninstallAppConfirm": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"softenThemeColor": "Soften theme color",
|
||||
"@softenThemeColor": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"softenThemeColorSubtitle": "Use a soft Material 3 palette derived from the selected color instead of the exact color.",
|
||||
"@softenThemeColorSubtitle": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2043,5 +2043,7 @@
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"areYouSureDeletePost": "Eliminar"
|
||||
"areYouSureDeletePost": "Eliminar",
|
||||
"softenThemeColor": "Suavizar cor do tema",
|
||||
"softenThemeColorSubtitle": "Usa uma paleta suave do Material 3 derivada da cor escolhida em vez da cor exata."
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ class ChatListController extends State<ChatList>
|
||||
)
|
||||
.toList();
|
||||
|
||||
var action = await showModalActionPopup<ChatContextAction>(
|
||||
final action = await showModalActionPopup<ChatContextAction>(
|
||||
context: posContext,
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: [
|
||||
@@ -564,36 +564,6 @@ class ChatListController extends State<ChatList>
|
||||
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.leave,
|
||||
label: room.membership == Membership.invite
|
||||
? l10n.delete
|
||||
: l10n.leave,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
if (room.membership == Membership.invite)
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.block,
|
||||
label: l10n.block,
|
||||
icon: const Icon(Icons.block_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
if (room.membership == Membership.join)
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.showMore,
|
||||
label: l10n.more,
|
||||
icon: Icon(Icons.adaptive.more_outlined),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!posContext.mounted || !mounted) return;
|
||||
if (action == ChatContextAction.showMore) {
|
||||
action = await showModalActionPopup<ChatContextAction>(
|
||||
context: posContext,
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: [
|
||||
if (!room.isFavourite)
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.lowPriority,
|
||||
@@ -625,8 +595,24 @@ class ChatListController extends State<ChatList>
|
||||
icon: const Icon(Icons.group_work_outlined),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.leave,
|
||||
label: room.membership == Membership.invite
|
||||
? l10n.delete
|
||||
: l10n.leave,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
if (room.membership == Membership.invite)
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.block,
|
||||
label: l10n.block,
|
||||
icon: const Icon(Icons.block_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!posContext.mounted || !mounted) return;
|
||||
|
||||
if (action == null) return;
|
||||
if (!mounted) return;
|
||||
@@ -753,8 +739,6 @@ class ChatListController extends State<ChatList>
|
||||
future: () => room.removeTag(activeTag!),
|
||||
);
|
||||
return;
|
||||
case ChatContextAction.showMore:
|
||||
throw ('Should not be handled!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,5 +1005,4 @@ enum ChatContextAction {
|
||||
leave,
|
||||
addToSpace,
|
||||
block,
|
||||
showMore,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget {
|
||||
class ImageViewerController extends State<ImageViewer> {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
/// One [TransformationController] per image, so the zoom of one page never
|
||||
/// leaks into its neighbours.
|
||||
final Map<String, TransformationController> _transformations = {};
|
||||
|
||||
int _pointerCount = 0;
|
||||
bool _isZoomed = false;
|
||||
|
||||
/// True while an image is zoomed in, so panning moves the image instead of
|
||||
/// switching to the next one.
|
||||
bool get isZoomed => _isZoomed;
|
||||
|
||||
/// The [PageView] must not compete for the gesture while pinching or while
|
||||
/// an image is zoomed, otherwise the swipe recognizer wins the gesture arena
|
||||
/// and pinch-to-zoom never happens.
|
||||
bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed;
|
||||
|
||||
TransformationController transformationControllerFor(Event event) =>
|
||||
_transformations.putIfAbsent(event.eventId, () {
|
||||
final controller = TransformationController();
|
||||
controller.addListener(_onTransformChanged);
|
||||
return controller;
|
||||
});
|
||||
|
||||
void onPointerDown() {
|
||||
if (!mounted) return;
|
||||
setState(() => _pointerCount++);
|
||||
}
|
||||
|
||||
void onPointerUp() {
|
||||
if (!mounted || _pointerCount == 0) return;
|
||||
setState(() => _pointerCount--);
|
||||
}
|
||||
|
||||
void _onTransformChanged() {
|
||||
if (!mounted) return;
|
||||
final zoomed = _transformations.values.any(
|
||||
(controller) => controller.value.getMaxScaleOnAxis() > 1.01,
|
||||
);
|
||||
if (zoomed == _isZoomed) return;
|
||||
setState(() => _isZoomed = zoomed);
|
||||
}
|
||||
|
||||
/// Resets the zoom of every image, e.g. after moving to another one.
|
||||
void resetZooms() {
|
||||
for (final controller in _transformations.values) {
|
||||
if (!controller.value.isIdentity()) {
|
||||
controller.value = Matrix4.identity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
|
||||
}
|
||||
|
||||
Future<void> prevImage() async {
|
||||
if (!canGoBack) return;
|
||||
resetZooms();
|
||||
await pageController.previousPage(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
|
||||
}
|
||||
|
||||
Future<void> nextImage() async {
|
||||
if (!canGoNext) return;
|
||||
resetZooms();
|
||||
await pageController.nextPage(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
@@ -112,23 +167,57 @@ class ImageViewerController extends State<ImageViewer> {
|
||||
|
||||
static const maxScaleFactor = 1.5;
|
||||
|
||||
/// Closes the viewer.
|
||||
///
|
||||
/// Always targets the root navigator, because the viewer is opened with
|
||||
/// `showDialog` on the root navigator while the chat room lives in a nested
|
||||
/// navigator of the go_router shell. Using a relative navigator here would
|
||||
/// either pop nothing or pop the wrong route.
|
||||
void closeAction() => Navigator.of(context, rootNavigator: true).pop();
|
||||
|
||||
/// Go back if user swiped it away
|
||||
void onInteractionEnds(ScaleEndDetails endDetails) {
|
||||
if (PlatformInfos.usesTouchscreen == false) {
|
||||
if (isZoomed) return;
|
||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||
MediaQuery.sizeOf(context).height * maxScaleFactor) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
closeAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _transformations.values) {
|
||||
controller.removeListener(_onTransformChanged);
|
||||
controller.dispose();
|
||||
}
|
||||
_transformations.clear();
|
||||
focusNode.dispose();
|
||||
pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ImageViewerView(this);
|
||||
Widget build(BuildContext context) => BackButtonListener(
|
||||
// A [PopScope] alone is not enough here: go_router's `popRoute()` asks
|
||||
// `GoRouterDelegate._findCurrentNavigators()`, which returns the shell
|
||||
// navigator BEFORE the root one. The viewer is a dialog on the root
|
||||
// navigator, so the shell navigator never sees its [PopEntry], pops the
|
||||
// chat room instead and returns `true`, leaving the image on screen.
|
||||
// A [BackButtonListener] is asked before the router gets the request at
|
||||
// all, so the viewer gets to close itself first.
|
||||
onBackButtonPressed: () async {
|
||||
closeAction();
|
||||
return true;
|
||||
},
|
||||
child: PopScope<Object?>(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
closeAction();
|
||||
},
|
||||
child: ImageViewerView(this),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget {
|
||||
foregroundColor: Colors.white,
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
onTap: controller.closeAction,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black.withAlpha(128),
|
||||
extendBodyBehindAppBar: true,
|
||||
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
|
||||
leading: IconButton(
|
||||
style: iconButtonStyle,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
onPressed: controller.closeAction,
|
||||
color: Colors.white,
|
||||
tooltip: L10n.of(context).close,
|
||||
),
|
||||
@@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget {
|
||||
KeyboardListener(
|
||||
focusNode: controller.focusNode,
|
||||
onKeyEvent: controller.onKeyEvent,
|
||||
child: PageView.builder(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: controller.pageController,
|
||||
itemCount: controller.allEvents.length,
|
||||
itemBuilder: (context, i) {
|
||||
final event = controller.allEvents[i];
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Video:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 52.0),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
// Ignore taps to not go back here:
|
||||
onTap: () {},
|
||||
child: EventVideoPlayer(event),
|
||||
),
|
||||
),
|
||||
);
|
||||
case MessageTypes.Image:
|
||||
case MessageTypes.Sticker:
|
||||
default:
|
||||
return InteractiveViewer(
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
onInteractionEnd: controller.onInteractionEnds,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: event.eventId,
|
||||
child: Listener(
|
||||
onPointerDown: (_) => controller.onPointerDown(),
|
||||
onPointerUp: (_) => controller.onPointerUp(),
|
||||
onPointerCancel: (_) => controller.onPointerUp(),
|
||||
child: PageView.builder(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: controller.pageController,
|
||||
physics: controller.canSwipeToNextImage
|
||||
? const PageScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
itemCount: controller.allEvents.length,
|
||||
itemBuilder: (context, i) {
|
||||
final event = controller.allEvents[i];
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Video:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 52.0),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
// Ignore taps to not go back here:
|
||||
onTap: () {},
|
||||
child: MxcImage(
|
||||
key: ValueKey(event.eventId),
|
||||
event: event,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
animated: true,
|
||||
child: EventVideoPlayer(event),
|
||||
),
|
||||
),
|
||||
);
|
||||
case MessageTypes.Image:
|
||||
case MessageTypes.Sticker:
|
||||
default:
|
||||
return InteractiveViewer(
|
||||
transformationController: controller
|
||||
.transformationControllerFor(event),
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
onInteractionEnd: controller.onInteractionEnds,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: event.eventId,
|
||||
child: GestureDetector(
|
||||
// Ignore taps to not go back here:
|
||||
onTap: () {},
|
||||
child: MxcImage(
|
||||
key: ValueKey(event.eventId),
|
||||
event: event,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
animated: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hovered)
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||
import 'package:tatchat/pages/image_viewer/image_viewer.dart';
|
||||
import 'package:tatchat/pages/image_viewer/video_player.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
@@ -306,6 +307,19 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Long press on a grid cell: open the same fullscreen viewer a room image
|
||||
/// uses, instead of the bottom sheet, so pinch-to-zoom and swiping between
|
||||
/// the media of the mural are available there too.
|
||||
void _openMediaFullscreen(Event event) {
|
||||
final timeline = this.timeline;
|
||||
if (timeline == null) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
ImageViewer(event, timeline: timeline, outerContext: context),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -395,6 +409,7 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
client: room.client,
|
||||
cellSize: gridCellSize,
|
||||
onTap: () => _openMedia(event),
|
||||
onLongPress: () => _openMediaFullscreen(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -656,12 +671,14 @@ class _MediaCell extends StatelessWidget {
|
||||
final Client client;
|
||||
final double cellSize;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _MediaCell({
|
||||
required this.event,
|
||||
required this.client,
|
||||
required this.cellSize,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -678,6 +695,7 @@ class _MediaCell extends StatelessWidget {
|
||||
: null;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Stack(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:tatchat/utils/file_selector.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/theme_builder.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'settings_style_view.dart';
|
||||
@@ -22,11 +23,18 @@ class SettingsStyle extends StatefulWidget {
|
||||
}
|
||||
|
||||
class SettingsStyleController extends State<SettingsStyle> {
|
||||
void setChatColor(Color? color) {
|
||||
Future<void> setChatColor(Color? color) async {
|
||||
final client = Matrix.of(context).client;
|
||||
final themeController = ThemeController.of(context);
|
||||
AppSettings.colorSchemeSeedInt.setItem(
|
||||
color?.toARGB32() ?? AppSettings.colorSchemeSeedInt.defaultValue,
|
||||
);
|
||||
ThemeController.of(context).setPrimaryColor(color);
|
||||
await themeController.setPrimaryColor(color);
|
||||
try {
|
||||
await client.setApplicationAccountColor(color?.toARGB32());
|
||||
} catch (e, s) {
|
||||
Logs().e('Unable to save chat color', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setWallpaper() async {
|
||||
@@ -105,16 +113,30 @@ class SettingsStyleController extends State<SettingsStyle> {
|
||||
});
|
||||
}
|
||||
|
||||
void deleteChatWallpaper() => showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => Matrix.of(context).client.setApplicationAccountConfig(
|
||||
const ApplicationAccountConfig(wallpaperUrl: null, wallpaperBlur: null),
|
||||
),
|
||||
);
|
||||
void deleteChatWallpaper() {
|
||||
final client = Matrix.of(context).client;
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setApplicationAccountConfig(
|
||||
ApplicationAccountConfig(
|
||||
wallpaperUrl: null,
|
||||
wallpaperBlur: null,
|
||||
primaryColor: client.applicationAccountConfig.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeMode get currentTheme => ThemeController.of(context).themeMode;
|
||||
Color? get currentColor => ThemeController.of(context).primaryColor;
|
||||
|
||||
bool get softenThemeColor => ThemeController.of(context).softenPrimaryColor;
|
||||
|
||||
Future<void> setSoftenThemeColor(bool soften) async {
|
||||
await ThemeController.of(context).setSoftenPrimaryColor(soften);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode? newTheme) {
|
||||
if (newTheme == null) return;
|
||||
switch (newTheme) {
|
||||
|
||||
@@ -136,6 +136,13 @@ class SettingsStyleView extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.palette_outlined),
|
||||
title: Text(L10n.of(context).softenThemeColor),
|
||||
subtitle: Text(L10n.of(context).softenThemeColorSubtitle),
|
||||
value: controller.softenThemeColor,
|
||||
onChanged: controller.setSoftenThemeColor,
|
||||
),
|
||||
Divider(color: theme.dividerColor),
|
||||
ListTile(
|
||||
title: Text(
|
||||
@@ -176,24 +183,38 @@ class SettingsStyleView extends StatelessWidget {
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (accountConfig.wallpaperUrl != null)
|
||||
Opacity(
|
||||
opacity: controller.wallpaperOpacity,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: controller.wallpaperBlur,
|
||||
sigmaY: controller.wallpaperBlur,
|
||||
),
|
||||
child: MxcImage(
|
||||
key: ValueKey(accountConfig.wallpaperUrl),
|
||||
uri: accountConfig.wallpaperUrl,
|
||||
fit: BoxFit.cover,
|
||||
isThumbnail: true,
|
||||
width: FluffyThemes.columnWidth * 2,
|
||||
height: 212,
|
||||
),
|
||||
Opacity(
|
||||
opacity: accountConfig.wallpaperUrl != null
|
||||
? controller.wallpaperOpacity
|
||||
: 0.3,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: accountConfig.wallpaperUrl != null
|
||||
? controller.wallpaperBlur
|
||||
: 0.0,
|
||||
sigmaY: accountConfig.wallpaperUrl != null
|
||||
? controller.wallpaperBlur
|
||||
: 0.0,
|
||||
),
|
||||
child: accountConfig.wallpaperUrl != null
|
||||
? MxcImage(
|
||||
key: ValueKey(
|
||||
accountConfig.wallpaperUrl,
|
||||
),
|
||||
uri: accountConfig.wallpaperUrl,
|
||||
fit: BoxFit.cover,
|
||||
isThumbnail: true,
|
||||
width: FluffyThemes.columnWidth * 2,
|
||||
height: 212,
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/logo/background.png',
|
||||
fit: BoxFit.cover,
|
||||
width: FluffyThemes.columnWidth * 2,
|
||||
height: 212,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
@@ -213,12 +234,10 @@ class SettingsStyleView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12 + 12 + Avatar.defaultSize,
|
||||
right: 12,
|
||||
top: accountConfig.wallpaperUrl == null
|
||||
? 0
|
||||
: 12,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
@@ -246,12 +265,10 @@ class SettingsStyleView extends StatelessWidget {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 12,
|
||||
left: 12,
|
||||
top: accountConfig.wallpaperUrl == null
|
||||
? 0
|
||||
: 12,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
),
|
||||
child: Material(
|
||||
|
||||
@@ -27,20 +27,53 @@ extension ApplicationAccountConfigExtension on Client {
|
||||
wallpaperOpacity:
|
||||
config.wallpaperOpacity ?? currentConfig.wallpaperOpacity,
|
||||
wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur,
|
||||
primaryColor: config.primaryColor ?? currentConfig.primaryColor,
|
||||
softenThemeColor:
|
||||
config.softenThemeColor ?? currentConfig.softenThemeColor,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setApplicationAccountColor(int? primaryColor) {
|
||||
final currentConfig = applicationAccountConfig;
|
||||
return setApplicationAccountConfig(
|
||||
ApplicationAccountConfig(
|
||||
wallpaperUrl: currentConfig.wallpaperUrl,
|
||||
wallpaperOpacity: currentConfig.wallpaperOpacity,
|
||||
wallpaperBlur: currentConfig.wallpaperBlur,
|
||||
primaryColor: primaryColor,
|
||||
softenThemeColor: currentConfig.softenThemeColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setApplicationAccountSoftenThemeColor(bool soften) {
|
||||
final currentConfig = applicationAccountConfig;
|
||||
return setApplicationAccountConfig(
|
||||
ApplicationAccountConfig(
|
||||
wallpaperUrl: currentConfig.wallpaperUrl,
|
||||
wallpaperOpacity: currentConfig.wallpaperOpacity,
|
||||
wallpaperBlur: currentConfig.wallpaperBlur,
|
||||
primaryColor: currentConfig.primaryColor,
|
||||
softenThemeColor: soften,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApplicationAccountConfig {
|
||||
final Uri? wallpaperUrl;
|
||||
final double? wallpaperOpacity;
|
||||
final double? wallpaperBlur;
|
||||
final int? primaryColor;
|
||||
final bool? softenThemeColor;
|
||||
|
||||
const ApplicationAccountConfig({
|
||||
this.wallpaperUrl,
|
||||
this.wallpaperOpacity,
|
||||
this.wallpaperBlur,
|
||||
this.primaryColor,
|
||||
this.softenThemeColor,
|
||||
});
|
||||
|
||||
static double _sanitizedOpacity(double? opacity) {
|
||||
@@ -58,11 +91,15 @@ class ApplicationAccountConfig {
|
||||
json.tryGet<double>('wallpaper_opacity'),
|
||||
),
|
||||
wallpaperBlur: json.tryGet<double>('wallpaper_blur'),
|
||||
primaryColor: json.tryGet<int>('primary_color'),
|
||||
softenThemeColor: json.tryGet<bool>('soften_theme_color'),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'wallpaper_url': wallpaperUrl?.toString(),
|
||||
'wallpaper_opacity': wallpaperOpacity,
|
||||
'wallpaper_blur': wallpaperBlur,
|
||||
'primary_color': primaryColor,
|
||||
'soften_theme_color': softenThemeColor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,13 +72,12 @@ abstract class PlatformInfos {
|
||||
final l10n = L10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final version = await getVersion();
|
||||
if (!context.mounted) return;
|
||||
await m.showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => m.Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 40,
|
||||
),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 720,
|
||||
@@ -110,13 +109,52 @@ abstract class PlatformInfos {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.versionWithNumber(version),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'alpha',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Esta versão ainda está em fase alpha: o '
|
||||
'aplicativo está em desenvolvimento e ainda pode '
|
||||
'apresentar erros.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Linkify(
|
||||
text: aboutDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
|
||||
@@ -68,9 +68,11 @@ abstract class ForegroundServices {
|
||||
}
|
||||
final result = await FlutterForegroundTask.startService(
|
||||
serviceTypes: [ForegroundServiceTypes.shortService],
|
||||
notificationTitle: 'FluffyChat',
|
||||
notificationTitle: 'TatuChat',
|
||||
notificationText: l10n.loadingMessages,
|
||||
notificationIcon: NotificationIcon(metaDataName: 'ic_launcher'),
|
||||
notificationIcon: NotificationIcon(
|
||||
metaDataName: 'notifications_icon',
|
||||
),
|
||||
);
|
||||
final started = result is ServiceRequestSuccess;
|
||||
Logs().d('[PushHelper] Foreground service start: $started ($result)');
|
||||
|
||||
@@ -44,10 +44,13 @@ class Matrix extends StatefulWidget {
|
||||
|
||||
final SharedPreferences store;
|
||||
|
||||
final ValueChanged<Client?>? onActiveClientChanged;
|
||||
|
||||
const Matrix({
|
||||
this.child,
|
||||
required this.clients,
|
||||
required this.store,
|
||||
this.onActiveClientChanged,
|
||||
this.queryParameters,
|
||||
super.key,
|
||||
});
|
||||
@@ -89,11 +92,21 @@ class MatrixState extends State<Matrix> {
|
||||
final i = widget.clients.indexWhere((c) => c == cl);
|
||||
if (i != -1) {
|
||||
_activeClient = i;
|
||||
_notifyActiveClientChanged();
|
||||
} else {
|
||||
Logs().w('Tried to set an unknown client ${cl!.userID} as active');
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyActiveClientChanged() {
|
||||
final callback = widget.onActiveClientChanged;
|
||||
if (callback == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
callback(widget.clients.isEmpty ? null : client);
|
||||
});
|
||||
}
|
||||
|
||||
List<Client?>? get currentBundle {
|
||||
if (!hasComplexBundles) {
|
||||
return List.from(widget.clients);
|
||||
@@ -256,19 +269,27 @@ class MatrixState extends State<Matrix> {
|
||||
.where((state) => state == LoginState.loggedOut)
|
||||
.listen((_) {
|
||||
final loggedInWithMultipleClients = widget.clients.length > 1;
|
||||
final activeClient = client;
|
||||
final removedIndex = widget.clients.indexOf(c);
|
||||
final wasActive = removedIndex != -1 && activeClient == c;
|
||||
|
||||
_cancelSubs(c.clientName);
|
||||
widget.clients.remove(c);
|
||||
ClientManager.removeClientNameFromStore(c.clientName, store);
|
||||
InitWithRestoreExtension.deleteSessionBackup(name);
|
||||
if (removedIndex != -1 && _activeClient > removedIndex) {
|
||||
_activeClient--;
|
||||
}
|
||||
if (wasActive) {
|
||||
_activeClient = -1;
|
||||
}
|
||||
if (removedIndex != -1) {
|
||||
_notifyActiveClientChanged();
|
||||
}
|
||||
|
||||
if (loggedInWithMultipleClients) {
|
||||
final snackbarContext =
|
||||
TatuChatApp
|
||||
.router
|
||||
.routerDelegate
|
||||
.navigatorKey
|
||||
.currentContext ??
|
||||
TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
|
||||
context;
|
||||
|
||||
if (!snackbarContext.mounted) return;
|
||||
@@ -349,6 +370,7 @@ class MatrixState extends State<Matrix> {
|
||||
},
|
||||
);
|
||||
}
|
||||
_notifyActiveClientChanged();
|
||||
}
|
||||
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
|
||||
@@ -13,47 +13,69 @@ class MxcImageViewer extends StatelessWidget {
|
||||
|
||||
const MxcImageViewer(this.mxContent, {super.key});
|
||||
|
||||
/// The viewer is opened with `showDialog` on the root navigator, so closing
|
||||
/// it must always target the root navigator.
|
||||
static void _close(BuildContext context) =>
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconButtonStyle = IconButton.styleFrom(
|
||||
backgroundColor: Colors.black.withAlpha(200),
|
||||
foregroundColor: Colors.white,
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black.withAlpha(128),
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
style: iconButtonStyle,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.white,
|
||||
tooltip: L10n.of(context).close,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: InteractiveViewer(
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
onInteractionEnd: (endDetails) {
|
||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||
MediaQuery.sizeOf(context).height * 1.5) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
// Ignore taps to not go back here:
|
||||
onTap: () {},
|
||||
child: MxcImage(
|
||||
key: ValueKey(mxContent.toString()),
|
||||
uri: mxContent,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
animated: true,
|
||||
return BackButtonListener(
|
||||
// Same reason as the room image viewer: this dialog lives on the root
|
||||
// navigator, but go_router asks the shell navigator first, so only a
|
||||
// [BackButtonListener] (which runs before the router) can keep the back
|
||||
// button from popping the page underneath.
|
||||
onBackButtonPressed: () async {
|
||||
_close(context);
|
||||
return true;
|
||||
},
|
||||
child: PopScope<Object?>(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
_close(context);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => _close(context),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black.withAlpha(128),
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
style: iconButtonStyle,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _close(context),
|
||||
color: Colors.white,
|
||||
tooltip: L10n.of(context).close,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: InteractiveViewer(
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
onInteractionEnd: (endDetails) {
|
||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||
MediaQuery.sizeOf(context).height * 1.5) {
|
||||
_close(context);
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
// Ignore taps to not go back here:
|
||||
onTap: () {},
|
||||
child: MxcImage(
|
||||
key: ValueKey(mxContent.toString()),
|
||||
uri: mxContent,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
animated: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -65,38 +65,48 @@ class TatuChatApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ThemeBuilder(
|
||||
builder: (context, themeMode, primaryColor) => MaterialApp.router(
|
||||
title: AppSettings.applicationName.value,
|
||||
themeMode: themeMode,
|
||||
theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor),
|
||||
darkTheme: FluffyThemes.buildTheme(
|
||||
context,
|
||||
Brightness.dark,
|
||||
primaryColor,
|
||||
),
|
||||
scrollBehavior: CustomScrollBehavior(),
|
||||
localizationsDelegates: [
|
||||
...L10n.localizationsDelegates,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
...GlobalCupertinoLocalizations.delegates,
|
||||
],
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
routerConfig: router,
|
||||
builder: (context, child) => AppLockWidget(
|
||||
pincode: appLockSettings.pincode,
|
||||
useBiometrics: appLockSettings.useBiometrics,
|
||||
isLoggedIn: clients.any((client) => client.isLogged()),
|
||||
// Need a navigator above the Matrix widget for
|
||||
// displaying dialogs
|
||||
child: Matrix(
|
||||
clients: clients,
|
||||
store: store,
|
||||
child: CallOverlay(
|
||||
child: testWidget ?? child ?? const SizedBox.shrink(),
|
||||
builder: (context, themeMode, primaryColor, themeController) =>
|
||||
MaterialApp.router(
|
||||
title: AppSettings.applicationName.value,
|
||||
themeMode: themeMode,
|
||||
theme: FluffyThemes.buildTheme(
|
||||
context,
|
||||
Brightness.light,
|
||||
primaryColor,
|
||||
themeController.softenPrimaryColor,
|
||||
),
|
||||
darkTheme: FluffyThemes.buildTheme(
|
||||
context,
|
||||
Brightness.dark,
|
||||
primaryColor,
|
||||
themeController.softenPrimaryColor,
|
||||
),
|
||||
scrollBehavior: CustomScrollBehavior(),
|
||||
localizationsDelegates: [
|
||||
...L10n.localizationsDelegates,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
...GlobalCupertinoLocalizations.delegates,
|
||||
],
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
routerConfig: router,
|
||||
builder: (context, child) => AppLockWidget(
|
||||
pincode: appLockSettings.pincode,
|
||||
useBiometrics: appLockSettings.useBiometrics,
|
||||
isLoggedIn: clients.any((client) => client.isLogged()),
|
||||
// Need a navigator above the Matrix widget for
|
||||
// displaying dialogs
|
||||
child: Matrix(
|
||||
clients: clients,
|
||||
store: store,
|
||||
onActiveClientChanged: (client) {
|
||||
themeController.setAccountClient(client);
|
||||
},
|
||||
child: CallOverlay(
|
||||
child: testWidget ?? child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
import 'package:tatchat/utils/color_value.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -15,6 +20,7 @@ class ThemeBuilder extends StatefulWidget {
|
||||
BuildContext context,
|
||||
ThemeMode themeMode,
|
||||
Color? primaryColor,
|
||||
ThemeController controller,
|
||||
)
|
||||
builder;
|
||||
|
||||
@@ -36,14 +42,42 @@ class ThemeController extends State<ThemeBuilder> {
|
||||
SharedPreferences? _sharedPreferences;
|
||||
ThemeMode? _themeMode;
|
||||
Color? _primaryColor;
|
||||
Color? _localPrimaryColor;
|
||||
Color? _accountPrimaryColor;
|
||||
bool _softenPrimaryColor = false;
|
||||
bool _localSoftenPrimaryColor = false;
|
||||
bool? _accountSoftenPrimaryColor;
|
||||
Client? _accountClient;
|
||||
StreamSubscription<SyncUpdate>? _accountClientSubscription;
|
||||
int _accountClientGeneration = 0;
|
||||
|
||||
ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
|
||||
|
||||
Color? get primaryColor => _primaryColor;
|
||||
|
||||
bool get softenPrimaryColor => _softenPrimaryColor;
|
||||
|
||||
static ThemeController of(BuildContext context) =>
|
||||
Provider.of<ThemeController>(context, listen: false);
|
||||
|
||||
void _updatePrimaryColor() {
|
||||
if (!mounted) return;
|
||||
final newColor = _accountPrimaryColor ?? _localPrimaryColor;
|
||||
if (newColor == _primaryColor) return;
|
||||
setState(() {
|
||||
_primaryColor = newColor;
|
||||
});
|
||||
}
|
||||
|
||||
void _updateSoftenPrimaryColor() {
|
||||
if (!mounted) return;
|
||||
final newValue = _accountSoftenPrimaryColor ?? _localSoftenPrimaryColor;
|
||||
if (newValue == _softenPrimaryColor) return;
|
||||
setState(() {
|
||||
_softenPrimaryColor = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadData(_) async {
|
||||
final preferences = _sharedPreferences ??=
|
||||
await SharedPreferences.getInstance();
|
||||
@@ -51,12 +85,16 @@ class ThemeController extends State<ThemeBuilder> {
|
||||
final rawThemeMode = preferences.getString(widget.themeModeSettingsKey);
|
||||
final rawColor = preferences.getInt(widget.primaryColorSettingsKey);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_themeMode = ThemeMode.values.singleWhereOrNull(
|
||||
(value) => value.name == rawThemeMode,
|
||||
);
|
||||
_primaryColor = rawColor == null ? null : Color(rawColor);
|
||||
_localPrimaryColor = rawColor == null ? null : Color(rawColor);
|
||||
_localSoftenPrimaryColor = AppSettings.softenThemeColor.value;
|
||||
});
|
||||
_updatePrimaryColor();
|
||||
_updateSoftenPrimaryColor();
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode newThemeMode) async {
|
||||
@@ -69,6 +107,12 @@ class ThemeController extends State<ThemeBuilder> {
|
||||
}
|
||||
|
||||
Future<void> setPrimaryColor(Color? newPrimaryColor) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_localPrimaryColor = newPrimaryColor;
|
||||
_accountPrimaryColor = newPrimaryColor;
|
||||
_primaryColor = newPrimaryColor;
|
||||
});
|
||||
final preferences = _sharedPreferences ??=
|
||||
await SharedPreferences.getInstance();
|
||||
if (newPrimaryColor == null) {
|
||||
@@ -79,9 +123,78 @@ class ThemeController extends State<ThemeBuilder> {
|
||||
newPrimaryColor.hexValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSoftenPrimaryColor(bool soften) async {
|
||||
await AppSettings.softenThemeColor.setItem(soften);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_primaryColor = newPrimaryColor;
|
||||
_localSoftenPrimaryColor = soften;
|
||||
_accountSoftenPrimaryColor = soften;
|
||||
_softenPrimaryColor = soften;
|
||||
});
|
||||
final client = _accountClient;
|
||||
if (client == null) return;
|
||||
try {
|
||||
await client.setApplicationAccountSoftenThemeColor(soften);
|
||||
} catch (err) {
|
||||
Logs().e('TatuChat: failed to sync soften theme color', err);
|
||||
}
|
||||
}
|
||||
|
||||
void setAccountClient(Client? client) {
|
||||
if (!mounted) return;
|
||||
final generation = ++_accountClientGeneration;
|
||||
_accountClientSubscription?.cancel();
|
||||
_accountClientSubscription = null;
|
||||
_accountClient = client;
|
||||
_accountPrimaryColor = null;
|
||||
_accountSoftenPrimaryColor = null;
|
||||
_updatePrimaryColor();
|
||||
_updateSoftenPrimaryColor();
|
||||
if (client == null) return;
|
||||
|
||||
unawaited(_loadAccountClientConfig(client, generation));
|
||||
_accountClientSubscription = client.onSync.stream
|
||||
.where(
|
||||
(sync) =>
|
||||
sync.accountData?.any(
|
||||
(accountData) =>
|
||||
accountData.type ==
|
||||
ApplicationAccountConfigExtension.accountDataKey,
|
||||
) ??
|
||||
false,
|
||||
)
|
||||
.listen((_) => _applyAccountClientConfig(client, generation));
|
||||
}
|
||||
|
||||
Future<void> _loadAccountClientConfig(Client client, int generation) async {
|
||||
final accountDataLoading = client.accountDataLoading;
|
||||
if (accountDataLoading != null) {
|
||||
try {
|
||||
await accountDataLoading;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!mounted ||
|
||||
generation != _accountClientGeneration ||
|
||||
!identical(_accountClient, client)) {
|
||||
return;
|
||||
}
|
||||
_applyAccountClientConfig(client, generation);
|
||||
}
|
||||
|
||||
void _applyAccountClientConfig(Client client, int generation) {
|
||||
if (!mounted ||
|
||||
generation != _accountClientGeneration ||
|
||||
!identical(_accountClient, client)) {
|
||||
return;
|
||||
}
|
||||
final config = client.applicationAccountConfig;
|
||||
final primaryColor = config.primaryColor;
|
||||
_accountPrimaryColor = primaryColor == null ? null : Color(primaryColor);
|
||||
_accountSoftenPrimaryColor = config.softenThemeColor;
|
||||
_updatePrimaryColor();
|
||||
_updateSoftenPrimaryColor();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -95,9 +208,19 @@ class ThemeController extends State<ThemeBuilder> {
|
||||
return Provider(
|
||||
create: (_) => this,
|
||||
child: DynamicColorBuilder(
|
||||
builder: (light, _) =>
|
||||
widget.builder(context, themeMode, primaryColor ?? light?.primary),
|
||||
builder: (light, _) => widget.builder(
|
||||
context,
|
||||
themeMode,
|
||||
primaryColor ?? light?.primary,
|
||||
this,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountClientSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
|
||||
// Disable Impeller to avoid a black screen on hybrid Intel/NVIDIA GPUs.
|
||||
fl_dart_project_set_enable_impeller(project, FALSE);
|
||||
|
||||
FlView* view = fl_view_new(project);
|
||||
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
|
||||
|
||||
@@ -118,6 +118,7 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/logo/background.png
|
||||
- assets/logo/mini/
|
||||
- assets/miniapps/
|
||||
- assets/sounds/
|
||||
|
||||
114
test/account_config_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
// SPDX-FileCopyrightText: 2026 TatuChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
|
||||
void main() {
|
||||
group('ApplicationAccountConfig', () {
|
||||
test('Parses primary color from account data', () {
|
||||
final config = ApplicationAccountConfig.fromJson({
|
||||
'primary_color': 0xFF6750A4,
|
||||
});
|
||||
expect(config.primaryColor, 0xFF6750A4);
|
||||
});
|
||||
|
||||
test('Ignores primary color of unexpected type', () {
|
||||
expect(
|
||||
ApplicationAccountConfig.fromJson({
|
||||
'primary_color': '4286369748',
|
||||
}).primaryColor,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
ApplicationAccountConfig.fromJson({'primary_color': 1.5}).primaryColor,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('Defaults primary color to null when absent', () {
|
||||
expect(ApplicationAccountConfig.fromJson({}).primaryColor, isNull);
|
||||
});
|
||||
|
||||
test('Serializes primary color', () {
|
||||
const config = ApplicationAccountConfig(primaryColor: 0xFF00D084);
|
||||
expect(config.toJson()['primary_color'], 0xFF00D084);
|
||||
});
|
||||
|
||||
test('Serializes missing primary color as null', () {
|
||||
const config = ApplicationAccountConfig(wallpaperOpacity: 0.5);
|
||||
expect(config.toJson()['primary_color'], isNull);
|
||||
});
|
||||
|
||||
test('Round trips through json', () {
|
||||
final config = ApplicationAccountConfig(
|
||||
wallpaperUrl: Uri.parse('mxc://example.invalid/wallpaper'),
|
||||
wallpaperOpacity: 0.3,
|
||||
wallpaperBlur: 4,
|
||||
primaryColor: 0xFF6750A4,
|
||||
softenThemeColor: true,
|
||||
);
|
||||
final parsed = ApplicationAccountConfig.fromJson(config.toJson());
|
||||
expect(parsed.wallpaperUrl, config.wallpaperUrl);
|
||||
expect(parsed.wallpaperOpacity, config.wallpaperOpacity);
|
||||
expect(parsed.wallpaperBlur, config.wallpaperBlur);
|
||||
expect(parsed.primaryColor, config.primaryColor);
|
||||
expect(parsed.softenThemeColor, config.softenThemeColor);
|
||||
});
|
||||
|
||||
test('Keeps existing wallpaper defaults when parsing color only', () {
|
||||
final config = ApplicationAccountConfig.fromJson({
|
||||
'primary_color': 0xFF6750A4,
|
||||
});
|
||||
expect(config.wallpaperUrl, isNull);
|
||||
expect(config.wallpaperOpacity, 1);
|
||||
expect(config.wallpaperBlur, isNull);
|
||||
});
|
||||
|
||||
test('Parses soften theme color from account data', () {
|
||||
expect(
|
||||
ApplicationAccountConfig.fromJson({
|
||||
'soften_theme_color': true,
|
||||
}).softenThemeColor,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApplicationAccountConfig.fromJson({
|
||||
'soften_theme_color': false,
|
||||
}).softenThemeColor,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('Ignores soften theme color of unexpected type', () {
|
||||
expect(
|
||||
ApplicationAccountConfig.fromJson({
|
||||
'soften_theme_color': 'yes',
|
||||
}).softenThemeColor,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('Defaults soften theme color to null when absent', () {
|
||||
expect(ApplicationAccountConfig.fromJson({}).softenThemeColor, isNull);
|
||||
});
|
||||
|
||||
test('Serializes soften theme color', () {
|
||||
const config = ApplicationAccountConfig(softenThemeColor: true);
|
||||
expect(config.toJson()['soften_theme_color'], true);
|
||||
});
|
||||
|
||||
test('Serializes missing soften theme color as null', () {
|
||||
const config = ApplicationAccountConfig(primaryColor: 0xFF00D084);
|
||||
expect(config.toJson()['soften_theme_color'], isNull);
|
||||
});
|
||||
|
||||
test('Round trips soften theme color through json', () {
|
||||
final parsed = ApplicationAccountConfig.fromJson(
|
||||
const ApplicationAccountConfig(softenThemeColor: true).toJson(),
|
||||
);
|
||||
expect(parsed.softenThemeColor, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||