diff --git a/NOTAS-EMULADOR.md b/NOTAS-EMULADOR.md new file mode 100644 index 0000000..016154b --- /dev/null +++ b/NOTAS-EMULADOR.md @@ -0,0 +1,89 @@ +# Notas: Execução do TatuChat em Emulador Android + +Data: 2026-09-12 + +## Objetivo + +Executar o projeto TatuChat (Flutter) em um emulador Android com janela visível. + +## Ambiente + +| Item | Valor | +|------|-------| +| OS | Linux (Freedesktop SDK 25.08, Flatpak runtime) | +| Flutter | 3.47.3 (Dart 3.13.3) | +| Android SDK | /home/welton/Android/Sdk (versão 37, emulator 37.1.11) | +| JDK | 17 (Temurin, via JAVA_HOME) | +| Imagem de sistema | android-36, google_apis, x86_64 | +| Sessão | Terminal integrado do VSCodium (sandbox Flatpak `com.vscodium.codium`) | + +## Problemas encontrados e soluções + +### 1. Nenhum AVD disponível inicialmente +Não havia AVDs criados. Imagem de sistema `android-36/google_apis/x86_64` já instalada. +- Criação: `avdmanager create avd -n tatuchat -k "system-images;android-36;google_apis;x86_64" -d pixel_7` +- **Atenção:** `avdmanager` (Java) criou o AVD em `~/.var/app/com.vscodium.codium/config/.android/avd/` (home do sandbox), diferente de `~/.android/avd` que o emulador espera. Corrigido copiando para `~/.android/avd` e ajustando o `path=` nos arquivos `.ini`. + +### 2. Emulador não abre janela dentro do sandbox Flatpak +Erro original: `Could not load the Qt platform plugin "xcb"` — o Qt do emulador não tem plugin Wayland e não há display X11 dentro do sandbox. +- Solução 1 (provisória): rodar headless com `-no-window` e `QT_QPA_PLATFORM=offscreen`. +- Solução 2 (definitiva): rodar o emulador **na sessão do host** (onde `DISPLAY=:0` existe) via `flatpak-spawn --host`, destacando o processo com `setsid` para não ser morto com o terminal. + +### 3. Bug no AndroidManifest que impedia o app de iniciar +O projeto é um fork do FluffyChat renomeado para TatuChat. O `namespace` e `applicationId` foram alterados para `chat.fluffy.tatuchat`, **mas**: +- `android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt` está no pacote `chat.fluffy.fluffychat` +- O manifest usava `android:name=".MainActivity"`, que resolvia para `chat.fluffy.tatuchat.MainActivity` (inexistente) + +Resultado: crash na inicialização com `ClassNotFoundException: chat.fluffy.tatuchat.MainActivity`. + +**Correção aplicada** em `android/app/src/main/AndroidManifest.xml:39`: +```xml + +android:name=".MainActivity" + +android:name="chat.fluffy.fluffychat.MainActivity" +``` + +Observação pendente: o `MainActivity.kt` (e o `FcmPushService.kt`, que está com código comentado) ainda vivem no pacote `chat.fluffy.fluffychat`. Uma limpeza futura deveria mover as classes para `chat.fluffy.tatuchat` e limpar as referências relativas do manifest (`.MainActivity`, `.FcmPushService` — este último aponta para classe que nem existe mais). + +## Como rodar o emulador em janela visível + +### Você (terminal normal do host) +```bash +# iniciar emulador com janela +~/Android/Sdk/emulator/emulator -avd tatuchat + +# rodar o app em outro terminal +cd ~/Documentos/Projetos/tatuchat +flutter run -d emulator-5554 +``` + +### Comandos equivalentes usados daqui (via flatpak-spawn) +```bash +# encerrar emulador anterior +adb emu kill + +# copiar AVD para a pasta padrão (se ainda não foi feito) +mkdir -p ~/.android/avd +cp -r ~/.var/app/com.vscodium.codium/config/.android/avd/. ~/.android/avd/ + +# iniciar emulador na sessão do host (janela visível) +flatpak-spawn --host sh -c 'export HOME=/home/welton; unset QT_QPA_PLATFORM; export DISPLAY=:0; setsid /home/welton/Android/Sdk/emulator/emulator -avd tatuchat -gpu swiftshader_indirect -no-boot-anim > /tmp/emu_run.log 2>&1 < /dev/null & echo started' + +# build + instalação manual (quando o hot reload da CLI falha no sandbox) +flutter build apk --debug +adb install -r build/app/outputs/flutter-apk/app-debug.apk +adb shell am start -n chat.fluffy.tatuchat/chat.fluffy.fluffychat.MainActivity +``` + +## Estado atual + +- Emulador `tatuchat` rodando com janela na sessão do host (boot OK). +- App TatuChat instalado e em execução, foco em `chat.fluffy.tatuchat/chat.fluffy.fluffychat.MainActivity`. +- Build: `flutter build apk --debug` gera `build/app/outputs/flutter-apk/app-debug.apk`. + +## Observações + +- `flutter run -d emulator-5554` pode falhar com `Error waiting for a debug connection: The log reader stopped unexpectedly` ao rodar a partir do sandbox; nesse caso o APK é instalado normalmente e basta abrir via `adb shell am start`. +- O AVD usa imagem `android-36` (compileSdk do projeto é 37) — compatível para execução. +- Arquivos de log do emulador no host: `/tmp/emu_run.log`. \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 54ffc12..2df189d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ android:localeConfig="@xml/locale_config" > defaultPageBuilder( + context, + state, + NewGroup( + createGroupType: CreateGroupType.canal, + spaceId: state.uri.queryParameters['space_id'], + ), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'newmural', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + NewGroup( + createGroupType: CreateGroupType.mural, + spaceId: state.uri.queryParameters['space_id'], + ), + ), + redirect: loggedOutRedirect, + ), ShellRoute( pageBuilder: (context, state, child) => defaultPageBuilder( context, diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 07adfc4..fd28ce3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -679,6 +679,15 @@ "type": "String", "placeholders": {} }, + "canal": "Canal", + "canalIsPublic": "Canal is public", + "canalName": "Canal name", + "canalOnlyAdminCanPost": "Only canal admins are allowed to post in this room", + "createCanalAndInviteUsers": "Create a canal and invite users", + "createMuralAndInviteUsers": "Create a mural and invite users", + "mural": "Mural", + "muralIsPublic": "Mural is public", + "muralName": "Mural name", "currentlyActive": "Currently active", "@currentlyActive": { "type": "String", @@ -2025,6 +2034,8 @@ "whyIsThisMessageEncrypted": "Why is this message unreadable?", "noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to lose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings.", "newSpace": "New space", + "newCanal": "New canal", + "newMural": "New mural", "doNotShowAgain": "Do not show again", "wasDirectChatDisplayName": "Empty chat (was {oldDisplayName})", "@wasDirectChatDisplayName": { @@ -2036,6 +2047,8 @@ } }, "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities.", + "newCanalDescription": "A canal is a room where only admins can publish. Use it to broadcast official announcements.", + "newMuralDescription": "A mural is a shared bulletin board where everyone can post. Use it to leave notes for your community.", "encryptThisChat": "Encrypt this chat", "disableEncryptionWarning": "For security reasons you can not disable encryption in a chat, where it has been enabled before.", "sorryThatsNotPossible": "Sorry... that is not possible", @@ -2845,5 +2858,15 @@ "@noSpacesFound": { "type": "String", "placeholders": {} + }, + "noMuralsFound": "No murals here", + "@noMuralsFound": { + "type": "String", + "placeholders": {} + }, + "selectASpace": "Select a space", + "@selectASpace": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 1463fa4..15b860b 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1845,6 +1845,8 @@ "user": "Usuário", "custom": "Personalizado", "newSpace": "Novo espaço", + "newCanal": "Novo canal", + "newMural": "Novo mural", "countFiles": "{count} arquivos", "@countFiles": { "placeholders": { @@ -1874,6 +1876,8 @@ "unbanUserDescription": "O usuário poderá entrar novamente na conversa, caso tente.", "messagesStyle": "Mensagens:", "newSpaceDescription": "Os espaços permitem que você consolide suas conversas e construa comunidades públicas ou privadas.", + "newCanalDescription": "O canal é uma sala onde somente administradores podem publicar. Use-o para transmitir comunicados oficiais.", + "newMuralDescription": "O mural é um quadro de avisos onde todos podem publicar. Use-o para deixar recados para a sua comunidade.", "chatDescription": "Descrição da conversa", "encryptThisChat": "Criptografar esta conversa", "reopenChat": "Reabrir conversa", @@ -1963,6 +1967,15 @@ "placeholders": {} }, "createGroup": "Criar grupo", + "canal": "Canal", + "canalIsPublic": "O canal é público", + "canalName": "Nome do canal", + "canalOnlyAdminCanPost": "Somente administradores do canal podem publicar nesta sala", + "createCanalAndInviteUsers": "Criar um canal e convidar pessoas", + "createMuralAndInviteUsers": "Criar um mural e convidar pessoas", + "mural": "Mural", + "muralIsPublic": "O mural é público", + "muralName": "Nome do mural", "noBackupWarning": "Atenção! Se não ativar o backup de conversas, você perderá acesso a suas mensagens criptografadas. É altamente recomendável ativar o backup antes de sair.", "kickUserDescription": "O usuário foi expulso da conversa, mas não banido. Em conversas públicas, o usuário pode entrar novamente a qualquer momento.", "invite": "Convidar", @@ -2497,5 +2510,15 @@ "@noSpacesFound": { "type": "String", "placeholders": {} + }, + "noMuralsFound": "Nenhum mural por aqui", + "@noMuralsFound": { + "type": "String", + "placeholders": {} + }, + "selectASpace": "Selecione um espaço", + "@selectASpace": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 699d675..48e38a5 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -8,7 +8,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:cross_file/cross_file.dart'; import 'package:tatchat/config/app_config.dart'; -import 'package:tatchat/config/themes.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat_list/chat_list_view.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; @@ -101,13 +100,10 @@ class ChatListController extends State Future setActiveSpace(String spaceId) async { await Matrix.of(context).client.getRoomById(spaceId)!.postLoad(); if (!mounted) return; - if (!FluffyThemes.isColumnMode(context) && - !AppSettings.displayNavigationRail.value) { - await AppSettings.displayNavigationRail.setItem(true); - } setState(() { _activeSpaceId = spaceId; + homeTab = HomeTab.spaces; }); } @@ -304,14 +300,6 @@ class ChatListController extends State } } - void openNavrail() { - setState(() { - AppSettings.displayNavigationRail.setItem( - !AppSettings.displayNavigationRail.value, - ); - }); - } - void startSearch() { setState(() { isSearchMode = true; @@ -355,10 +343,6 @@ class ChatListController extends State context.push('/rooms/$spaceId/details'); } - // Needs to match GroupsSpacesEntry for 'separate group' checking. - List get spaces => - Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); - String? get activeChat => widget.activeChat; void _processIncomingSharedMedia(List files) { @@ -423,6 +407,7 @@ class ChatListController extends State void initState() { _initReceiveSharingIntent(); _activeSpaceId = widget.activeSpace; + if (widget.activeSpace != null) homeTab = HomeTab.spaces; scrollController.addListener(_onScroll); _waitForFirstSync(); @@ -984,6 +969,7 @@ class ChatListController extends State setState(() { activeFilter = ActiveFilter.allChats; _activeSpaceId = null; + homeTab = HomeTab.chats; Matrix.of(context).setActiveClient(client); }); _clientStream.add(client); @@ -993,6 +979,7 @@ class ChatListController extends State context.go('/rooms'); setState(() { _activeSpaceId = null; + homeTab = HomeTab.chats; Matrix.of(context).activeBundle = bundle; if (!Matrix.of( context, diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index fd08e9a..65cc8f2 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -4,13 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:cupertino_ui/cupertino_ui.dart'; -import 'package:tatchat/config/setting_keys.dart'; +import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart'; import 'package:tatchat/pages/chat_list/chat_list_item.dart'; import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:tatchat/pages/chat_list/search_title.dart'; -import 'package:tatchat/pages/chat_list/space_view.dart'; import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart'; import 'package:tatchat/widgets/avatar.dart'; @@ -20,7 +19,6 @@ import 'package:matrix/matrix.dart'; import '../../config/themes.dart'; import '../../widgets/adaptive_dialogs/user_dialog.dart'; import '../../widgets/matrix.dart'; -import 'chat_list_header.dart'; class ChatListViewBody extends StatelessWidget { final ChatListController controller; @@ -32,16 +30,6 @@ class ChatListViewBody extends StatelessWidget { final theme = Theme.of(context); final client = Matrix.of(context).client; - final activeSpace = controller.activeSpaceId; - if (activeSpace != null) { - return SpaceView( - key: ValueKey(activeSpace), - spaceId: activeSpace, - onBack: controller.clearActiveSpace, - onChatTab: controller.onChatTap, - activeChat: controller.activeChat, - ); - } final spaces = client.rooms.where((r) => r.isSpace); final spaceDelegateCandidates = {}; for (final space in spaces) { @@ -70,7 +58,9 @@ class ChatListViewBody extends StatelessWidget { final rooms = controller.filteredRooms .where( (room) => - !AppSettings.hideRoomsInSpaces.value || + !room.isSpace && + !room.tags.keys.contains(AppConfig.canalTag) && + !room.tags.keys.contains(AppConfig.muralTag) && spaceDelegateCandidates[room.id] == null, ) .toList(); @@ -78,7 +68,6 @@ class ChatListViewBody extends StatelessWidget { return CustomScrollView( controller: controller.scrollController, slivers: [ - ChatListHeader(controller: controller), SliverList( delegate: SliverChildListDelegate([ if (controller.isSearchMode) ...[ diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart deleted file mode 100644 index 5189704..0000000 --- a/lib/pages/chat_list/chat_list_header.dart +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-FileCopyrightText: 2019-Present Christian Kußowski -// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -import 'package:tatchat/config/setting_keys.dart'; -import 'package:tatchat/config/themes.dart'; -import 'package:tatchat/l10n/l10n.dart'; -import 'package:tatchat/pages/chat_list/chat_list.dart'; -import 'package:tatchat/pages/chat_list/client_chooser_button.dart'; -import 'package:tatchat/utils/sync_status_localization.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:matrix/matrix.dart'; - -import '../../widgets/matrix.dart'; - -class ChatListHeader extends StatelessWidget implements PreferredSizeWidget { - final ChatListController controller; - final bool globalSearch; - - const ChatListHeader({ - super.key, - required this.controller, - this.globalSearch = true, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final client = Matrix.of(context).client; - final isColumnMode = FluffyThemes.isColumnMode(context); - - return SliverAppBar( - floating: true, - toolbarHeight: 72, - pinned: isColumnMode, - scrolledUnderElevation: 0, - shape: isColumnMode - ? Border(bottom: BorderSide(color: theme.dividerColor, width: 1)) - : null, - backgroundColor: isColumnMode - ? theme.colorScheme.surface.withAlpha(240) - : Colors.transparent, - automaticallyImplyLeading: false, - title: StreamBuilder( - stream: client.onSyncStatus.stream, - builder: (context, snapshot) { - final status = - client.onSyncStatus.value ?? - const SyncStatusUpdate(SyncStatus.waitingForResponse); - final hide = - client.onSync.value != null && - status.status != SyncStatus.error && - client.prevBatch != null; - return TextField( - controller: controller.searchController, - focusNode: controller.searchFocusNode, - textInputAction: TextInputAction.search, - onChanged: (text) => - controller.onSearchEnter(text, globalSearch: globalSearch), - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - hintText: hide - ? L10n.of(context).searchChatsRooms - : status.calcLocalizedString(context), - hintStyle: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - - prefixIcon: hide - ? controller.isSearchMode - ? IconButton( - tooltip: L10n.of(context).cancel, - icon: const Icon(Icons.close_outlined), - onPressed: controller.cancelSearch, - color: theme.colorScheme.onPrimaryContainer, - ) - : isColumnMode || - (controller.spaces.isEmpty && - !AppSettings.displayNavigationRail.value) - ? IconButton( - tooltip: L10n.of(context).search, - onPressed: controller.startSearch, - icon: Icon( - Icons.search_outlined, - color: theme.colorScheme.onPrimaryContainer, - ), - ) - : IconButton( - tooltip: L10n.of(context).displayNavigationRail, - onPressed: controller.openNavrail, - icon: Icon( - Icons.menu, - color: theme.colorScheme.onPrimaryContainer, - ), - ) - : SizedBox( - width: 8, - height: 8, - child: Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - value: status.progress, - ), - ), - ), - suffixIcon: controller.isSearchMode && globalSearch - ? controller.isSearching - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 12, - ), - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ), - ) - : TextButton.icon( - onPressed: controller.setServer, - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(99), - ), - textStyle: const TextStyle(fontSize: 12), - ), - icon: const Icon(Icons.edit_outlined, size: 16), - label: Text( - controller.searchServer ?? - Matrix.of(context).client.homeserver!.host, - maxLines: 2, - ), - ) - : SizedBox(width: 0, child: ClientChooserButton(controller)), - ), - ); - }, - ), - ); - } - - @override - Size get preferredSize => const Size.fromHeight(56); -} diff --git a/lib/pages/chat_list/chat_list_view.dart b/lib/pages/chat_list/chat_list_view.dart index 107b11e..a4fde5a 100644 --- a/lib/pages/chat_list/chat_list_view.dart +++ b/lib/pages/chat_list/chat_list_view.dart @@ -4,14 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:material_ui/material_ui.dart'; -import 'package:tatchat/config/app_config.dart'; -import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/config/themes.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart'; -import 'package:tatchat/pages/chat_list/navigation_rail.dart'; import 'package:tatchat/pages/chat_list/start_chat_fab.dart'; import 'package:tatchat/pages/chat_list/tabs/channels_tab.dart'; import 'package:tatchat/pages/chat_list/tabs/chat_tab_bar.dart'; +import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; import 'package:tatchat/pages/chat_list/tabs/murals_tab.dart'; import 'package:tatchat/pages/chat_list/tabs/spaces_tab.dart'; @@ -25,23 +23,12 @@ class ChatListView extends StatelessWidget { @override Widget build(BuildContext context) { - final oneColumnSpacesMode = - !FluffyThemes.isColumnMode(context) && - AppSettings.displayNavigationRail.value; final isColumnMode = FluffyThemes.isColumnMode(context); final homeTab = controller.homeTab; - final showSpacesMode = homeTab == HomeTab.chats; - final showNavigationRail = - showSpacesMode && - (isColumnMode || AppSettings.displayNavigationRail.value); return PopScope( - canPop: !controller.isSearchMode && controller.activeSpaceId == null, + canPop: !controller.isSearchMode, onPopInvokedWithResult: (pop, _) { if (pop) return; - if (controller.activeSpaceId != null) { - controller.clearActiveSpace(); - return; - } if (controller.isSearchMode) { controller.cancelSearch(); return; @@ -56,91 +43,46 @@ class ChatListView extends StatelessWidget { onTabSelected: controller.setHomeTab, ), Expanded( - child: Row( - children: [ - Material( - color: Theme.of(context).colorScheme.surface, - child: AnimatedSize( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: showNavigationRail - ? SpacesNavigationRail( - activeSpaceId: controller.activeSpaceId, - onGoToChats: controller.clearActiveSpace, - onGoToSpaceId: controller.setActiveSpace, - ) - : SizedBox( - width: 0, - height: MediaQuery.sizeOf(context).height, - ), - ), - ), - if (showNavigationRail && isColumnMode) - Container(width: 1, color: Theme.of(context).dividerColor), - - Expanded( - child: Scaffold( - backgroundColor: showSpacesMode && oneColumnSpacesMode - ? Theme.of(context).colorScheme.surfaceContainer - : null, - body: SafeArea( - top: showSpacesMode && oneColumnSpacesMode, - bottom: false, - left: false, - right: false, - child: IndexedStack( - index: homeTab.index, - children: [ - Material( - clipBehavior: showSpacesMode && oneColumnSpacesMode - ? Clip.hardEdge - : Clip.none, - borderRadius: - showSpacesMode && oneColumnSpacesMode - ? BorderRadius.only( - topLeft: Radius.circular( - AppConfig.borderRadius, - ), - ) - : null, - color: showSpacesMode && oneColumnSpacesMode - ? Theme.of(context).colorScheme.surface - : null, - child: ChatListViewBody(controller), - ), - ChannelsTab(controller), - SpacesTab(controller), - MuralsTab(), - ], - ), + child: Scaffold( + body: SafeArea( + top: false, + bottom: false, + left: false, + right: false, + child: IndexedStack( + index: homeTab.index, + children: [ + ChatTabScaffold( + tab: HomeTab.chats, + isSearchMode: controller.isSearchMode, + onSearchPressed: controller.startSearch, + onSearchCancelled: () => controller.cancelSearch(), + searchController: controller.searchController, + searchFocusNode: controller.searchFocusNode, + onSearchChanged: (text) => + controller.onSearchEnter(text, globalSearch: true), + controller: controller, + child: ChatListViewBody(controller), ), - floatingActionButton: - showSpacesMode && - !controller.isSearchMode && - controller.activeSpaceId == null && - !isColumnMode - ? ValueListenableBuilder( - valueListenable: controller.scrolledToTop, - builder: (context, scrolledToTop, _) => StartChatFab( - extended: - scrolledToTop && - !AppSettings.displayNavigationRail.value, - ), - ) - : const SizedBox.shrink(), - bottomNavigationBar: isColumnMode - ? null - : ChatBottomTabBar( - currentTab: homeTab, - onTabSelected: controller.setHomeTab, - ), - ), + ChannelsTab(controller), + SpacesTab(controller), + MuralsTab(controller), + ], ), - ], + ), + floatingActionButton: !controller.isSearchMode && !isColumnMode + ? const StartChatFab() + : const SizedBox.shrink(), + bottomNavigationBar: isColumnMode + ? null + : ChatBottomTabBar( + currentTab: homeTab, + onTabSelected: controller.setHomeTab, + ), ), ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/chat_list/navi_rail_item.dart b/lib/pages/chat_list/navi_rail_item.dart index 1c9659b..2d7bb75 100644 --- a/lib/pages/chat_list/navi_rail_item.dart +++ b/lib/pages/chat_list/navi_rail_item.dart @@ -73,7 +73,7 @@ class NaviRailItem extends StatelessWidget { borderRadius: borderRadius, color: isSelected ? theme.colorScheme.secondaryContainer - : theme.colorScheme.surfaceContainerHighest, + : Colors.transparent, child: Tooltip( message: toolTip, child: InkWell( diff --git a/lib/pages/chat_list/navigation_rail.dart b/lib/pages/chat_list/navigation_rail.dart deleted file mode 100644 index 1f43537..0000000 --- a/lib/pages/chat_list/navigation_rail.dart +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-FileCopyrightText: 2019-Present Christian Kußowski -// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -import 'package:tatchat/config/app_config.dart'; -import 'package:tatchat/config/themes.dart'; -import 'package:tatchat/l10n/l10n.dart'; -import 'package:tatchat/pages/chat_list/navi_rail_item.dart'; -import 'package:tatchat/pages/chat_list/start_chat_fab.dart'; -import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:tatchat/utils/stream_extension.dart'; -import 'package:tatchat/widgets/avatar.dart'; -import 'package:tatchat/widgets/matrix.dart'; -import 'package:go_router/go_router.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:matrix/matrix.dart'; - -class SpacesNavigationRail extends StatelessWidget { - final String? activeSpaceId; - final void Function() onGoToChats; - final void Function(String) onGoToSpaceId; - - const SpacesNavigationRail({ - required this.activeSpaceId, - required this.onGoToChats, - required this.onGoToSpaceId, - super.key, - }); - - @override - Widget build(BuildContext context) { - final client = Matrix.of(context).client; - final coloredMode = !FluffyThemes.isColumnMode(context); - final theme = Theme.of(context); - return Material( - color: coloredMode ? theme.colorScheme.surfaceContainer : null, - child: SafeArea( - child: StreamBuilder( - key: ValueKey(client.userID.toString()), - stream: client.onSync.stream - .where((s) => s.hasRoomUpdate) - .rateLimit(const Duration(seconds: 1)), - builder: (context, _) { - final allSpaces = client.rooms - .where((room) => room.isSpace) - .toList(); - - return SizedBox( - width: FluffyThemes.isColumnMode(context) - ? FluffyThemes.navRailWidth - : FluffyThemes.navRailWidth - 8, - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: EdgeInsets.symmetric(vertical: 4), - scrollDirection: Axis.vertical, - itemCount: allSpaces.length + 2, - itemBuilder: (context, i) { - if (i == 0) { - return NaviRailItem( - isSelected: activeSpaceId == null, - onTap: onGoToChats, - icon: const Padding( - padding: EdgeInsets.all(12.0), - child: Icon(Icons.forum_outlined), - ), - selectedIcon: const Padding( - padding: EdgeInsets.all(12.0), - child: Icon(Icons.forum), - ), - toolTip: L10n.of(context).chats, - unreadBadgeFilter: (room) => true, - ); - } - i--; - if (i == allSpaces.length) { - return NaviRailItem( - isSelected: false, - onTap: () => context.go('/rooms/newspace'), - icon: const Padding( - padding: EdgeInsets.all(12.0), - child: Icon(Icons.add), - ), - toolTip: L10n.of(context).createNewSpace, - ); - } - final space = allSpaces[i]; - final displayname = allSpaces[i] - .getLocalizedDisplayname( - MatrixLocals(L10n.of(context)), - ); - final spaceChildrenIds = space.spaceChildren - .map((c) => c.roomId) - .toSet(); - return NaviRailItem( - toolTip: displayname, - isSelected: activeSpaceId == space.id, - onTap: () => onGoToSpaceId(allSpaces[i].id), - unreadBadgeFilter: (room) => - spaceChildrenIds.contains(room.id), - icon: Avatar( - mxContent: allSpaces[i].avatar, - name: displayname, - //size: 36, - shapeBorder: RoundedSuperellipseBorder( - side: BorderSide( - width: 1, - color: Theme.of(context).dividerColor, - ), - borderRadius: BorderRadius.circular( - AppConfig.spaceBorderRadius, - ), - ), - borderRadius: BorderRadius.circular( - AppConfig.spaceBorderRadius, - ), - ), - ); - }, - ), - ), - if (FluffyThemes.isColumnMode(context)) - Padding( - padding: const EdgeInsets.all(12.0), - child: StartChatFab(), - ), - ], - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 48eed11..167b30e 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -20,6 +20,7 @@ import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/hover_builder.dart'; import 'package:tatchat/widgets/matrix.dart'; +import 'package:tatchat/widgets/mxc_image.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart' as sdk; @@ -385,84 +386,113 @@ class _SpaceViewState extends State { final filter = _filterController.text.trim().toLowerCase(); return CustomScrollView( slivers: [ - SliverAppBar( - toolbarHeight: 72, - floating: true, - scrolledUnderElevation: 0, - backgroundColor: Colors.transparent, - automaticallyImplyLeading: false, - title: TextField( - controller: _filterController, - onChanged: (_) => setState(() {}), - textInputAction: TextInputAction.search, - decoration: InputDecoration( - filled: true, - fillColor: theme.colorScheme.secondaryContainer, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(99), - ), - contentPadding: EdgeInsets.zero, - hintText: L10n.of(context).search, - hintStyle: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.normal, - ), - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: IconButton( - onPressed: () {}, - icon: Icon( - Icons.search_outlined, - color: theme.colorScheme.onPrimaryContainer, - ), - ), - suffixIcon: PopupMenuButton( - icon: Avatar( - size: avatarSize, - mxContent: room.avatar, - name: displayname, - shapeBorder: RoundedSuperellipseBorder( - side: BorderSide( - width: 1, - color: theme.dividerColor, - ), - borderRadius: BorderRadius.circular( - AppConfig.spaceBorderRadius, - ), - ), - borderRadius: BorderRadius.circular( - AppConfig.spaceBorderRadius, - ), - ), - useRootNavigator: true, - onSelected: _onSpaceAction, - itemBuilder: (context) => [ - if (isAdmin) - PopupMenuItem( - value: SpaceActions.addChild, - child: ListTile( - leading: Icon(Icons.edit_square), - title: Text( - L10n.of(context).addChatOrSubSpace, + SliverToBoxAdapter( + child: _spaceCover( + theme: theme, + room: room, + displayname: displayname, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _filterController, + onChanged: (_) => setState(() {}), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + filled: true, + fillColor: + theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + contentPadding: EdgeInsets.zero, + hintText: L10n.of(context).search, + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + floatingLabelBehavior: + FloatingLabelBehavior.never, + prefixIcon: IconButton( + onPressed: () {}, + icon: Icon( + Icons.search_outlined, + color: + theme.colorScheme.onPrimaryContainer, ), ), - ), - PopupMenuItem( - value: SpaceActions.settings, - child: ListTile( - leading: Icon(Icons.settings_outlined), - title: Text(L10n.of(context).settings), + suffixIcon: PopupMenuButton( + icon: Avatar( + size: avatarSize, + mxContent: room.avatar, + name: displayname, + shapeBorder: RoundedSuperellipseBorder( + side: BorderSide( + width: 1, + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular( + AppConfig.spaceBorderRadius, + ), + ), + borderRadius: BorderRadius.circular( + AppConfig.spaceBorderRadius, + ), + ), + useRootNavigator: true, + onSelected: _onSpaceAction, + itemBuilder: (context) => [ + if (isAdmin) + PopupMenuItem( + value: SpaceActions.addChild, + child: ListTile( + leading: Icon(Icons.edit_square), + title: Text( + L10n.of( + context, + ).addChatOrSubSpace, + ), + ), + ), + PopupMenuItem( + value: SpaceActions.settings, + child: ListTile( + leading: Icon( + Icons.settings_outlined, + ), + title: Text( + L10n.of(context).settings, + ), + ), + ), + PopupMenuItem( + value: SpaceActions.leave, + child: ListTile( + leading: Icon(Icons.delete_outlined), + title: Text(L10n.of(context).leave), + ), + ), + ], + ), ), ), - PopupMenuItem( - value: SpaceActions.leave, - child: ListTile( - leading: Icon(Icons.delete_outlined), - title: Text(L10n.of(context).leave), + ), + const SizedBox(width: 8), + if (isAdmin) + IconButton.filledTonal( + tooltip: L10n.of(context).addChatOrSubSpace, + onPressed: () => context.go( + '/rooms/newgroup?space_id=${widget.spaceId}', ), + icon: const Icon(Icons.add), ), - ], - ), + ], ), ), ), @@ -641,4 +671,90 @@ class _SpaceViewState extends State { ), ); } + + Widget _spaceCover({ + required ThemeData theme, + required sdk.Room room, + required String displayname, + }) { + const coverHeight = 160.0; + final avatar = room.avatar; + return SizedBox( + height: coverHeight, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primaryContainer, + theme.colorScheme.secondaryContainer, + ], + ), + ), + ), + if (avatar != null) + MxcImage( + uri: avatar, + client: room.client, + fit: BoxFit.cover, + width: MediaQuery.sizeOf(context).width, + height: coverHeight, + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.55), + ], + stops: const [0.4, 1.0], + ), + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 12, + child: Row( + children: [ + Avatar( + mxContent: avatar, + name: displayname, + size: 56, + shapeBorder: RoundedSuperellipseBorder( + side: BorderSide( + width: 2, + color: theme.colorScheme.surface, + ), + borderRadius: BorderRadius.circular( + AppConfig.spaceBorderRadius, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + displayname, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } } diff --git a/lib/pages/chat_list/start_chat_fab.dart b/lib/pages/chat_list/start_chat_fab.dart index 7c89656..d9c9c98 100644 --- a/lib/pages/chat_list/start_chat_fab.dart +++ b/lib/pages/chat_list/start_chat_fab.dart @@ -3,33 +3,21 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -import 'package:tatchat/config/themes.dart'; -import 'package:tatchat/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; class StartChatFab extends StatelessWidget { - final bool extended; - const StartChatFab({this.extended = false, super.key}); + const StartChatFab({super.key}); @override Widget build(BuildContext context) { - return FloatingActionButton.extended( + return FloatingActionButton( heroTag: 'start_chat_fab', backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, onPressed: () => context.go('/rooms/newprivatechat'), - extendedIconLabelSpacing: extended ? 10 : 0, - extendedPadding: extended - ? null - : const EdgeInsets.symmetric(horizontal: 16), - label: AnimatedSize( - alignment: Alignment.centerLeft, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: extended ? Text(L10n.of(context).newChat) : SizedBox.shrink(), - ), - icon: const Icon(Icons.edit_square), + shape: const CircleBorder(), + child: const Icon(Icons.add), ); } -} +} \ No newline at end of file diff --git a/lib/pages/chat_list/tabs/channels_tab.dart b/lib/pages/chat_list/tabs/channels_tab.dart index a081ae4..b5d6a9f 100644 --- a/lib/pages/chat_list/tabs/channels_tab.dart +++ b/lib/pages/chat_list/tabs/channels_tab.dart @@ -5,75 +5,104 @@ import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart'; import 'package:tatchat/pages/chat_list/chat_list_item.dart'; +import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart'; import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; +import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/widgets/matrix.dart'; -class ChannelsTab extends StatelessWidget { +class ChannelsTab extends StatefulWidget { final ChatListController controller; const ChannelsTab(this.controller, {super.key}); + @override + State createState() => _ChannelsTabState(); +} + +class _ChannelsTabState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + bool _isSearchMode = false; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final client = Matrix.of(context).client; final theme = Theme.of(context); - return StreamBuilder( - key: ValueKey(client.userID.toString()), - stream: client.onSync.stream - .where((s) => s.hasRoomUpdate) - .rateLimit(const Duration(seconds: 1)), - builder: (context, _) { - final channels = client.rooms - .where((room) => !room.isSpace && !room.isDirectChat) - .toList(); - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Icon( - Icons.tag_outlined, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - L10n.of(context).channels, - style: theme.textTheme.titleLarge, - ), - ], - ), - ), - ), - if (channels.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyTabView( - icon: Icons.tag_outlined, - title: L10n.of(context).noChannelsFound, - ), - ) - else - SliverList.builder( - itemCount: channels.length, - itemBuilder: (context, i) { - final room = channels[i]; - return ChatListItem( - room, - key: Key('channel_list_item_${room.id}'), - onTap: () => controller.onChatTap(room), - activeChat: controller.activeChat == room.id, - ); - }, - ), - ], - ); + final searchText = _searchController.text.trim().toLowerCase(); + return ChatTabScaffold( + tab: HomeTab.channels, + isSearchMode: _isSearchMode, + onSearchPressed: () => setState(() => _isSearchMode = true), + onSearchCancelled: () { + setState(() { + _isSearchMode = false; + _searchController.clear(); + }); }, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: (_) => setState(() {}), + controller: widget.controller, + child: StreamBuilder( + key: ValueKey(client.userID.toString()), + stream: client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, _) { + final channels = client.rooms + .where((room) => room.tags.keys.contains(AppConfig.canalTag)) + .where( + (room) => + searchText.isEmpty || + room + .getLocalizedDisplayname(MatrixLocals(L10n.of(context))) + .toLowerCase() + .contains(searchText), + ) + .toList(); + return ColoredBox( + color: theme.colorScheme.surface, + child: CustomScrollView( + slivers: [ + if (channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyTabView( + icon: Icons.tag_outlined, + title: L10n.of(context).noChannelsFound, + ), + ) + else + SliverList.builder( + itemCount: channels.length, + itemBuilder: (context, i) { + final room = channels[i]; + return ChatListItem( + room, + key: Key('channel_list_item_${room.id}'), + onTap: () => widget.controller.onChatTap(room), + activeChat: widget.controller.activeChat == room.id, + ); + }, + ), + ], + ), + ); + }, + ), ); } } \ No newline at end of file diff --git a/lib/pages/chat_list/tabs/chat_tab_header.dart b/lib/pages/chat_list/tabs/chat_tab_header.dart new file mode 100644 index 0000000..4e13e00 --- /dev/null +++ b/lib/pages/chat_list/tabs/chat_tab_header.dart @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:material_ui/material_ui.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/pages/chat_list/chat_list.dart'; +import 'package:tatchat/pages/chat_list/client_chooser_button.dart'; +import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; + +class ChatTabHeader extends StatelessWidget { + final HomeTab tab; + final bool isSearchMode; + final VoidCallback onSearchPressed; + final VoidCallback onSearchCancelled; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ChatListController controller; + + const ChatTabHeader({ + super.key, + required this.tab, + required this.isSearchMode, + required this.onSearchPressed, + required this.onSearchCancelled, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = L10n.of(context); + return Material( + color: theme.colorScheme.surface, + child: Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: isSearchMode + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: TextField( + controller: searchController, + focusNode: searchFocusNode, + autofocus: true, + textInputAction: TextInputAction.search, + onChanged: onSearchChanged, + decoration: InputDecoration( + hintText: l10n.search, + filled: true, + fillColor: theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + contentPadding: EdgeInsets.zero, + prefixIcon: IconButton( + tooltip: l10n.cancel, + icon: const Icon(Icons.close_outlined), + onPressed: onSearchCancelled, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ) + : SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 12), + Icon(tab.icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text(tab.label(context), style: theme.textTheme.titleLarge), + const Spacer(), + IconButton( + tooltip: l10n.search, + icon: const Icon(Icons.search), + onPressed: onSearchPressed, + ), + const SizedBox(width: 4), + ClientChooserButton(controller), + const SizedBox(width: 8), + ], + ), + ), + ), + ); + } +} + +class ChatTabScaffold extends StatelessWidget { + final HomeTab tab; + final bool isSearchMode; + final VoidCallback onSearchPressed; + final VoidCallback onSearchCancelled; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onSearchChanged; + final ChatListController controller; + final Widget child; + + const ChatTabScaffold({ + super.key, + required this.tab, + required this.isSearchMode, + required this.onSearchPressed, + required this.onSearchCancelled, + required this.searchController, + required this.searchFocusNode, + required this.onSearchChanged, + required this.controller, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ChatTabHeader( + tab: tab, + isSearchMode: isSearchMode, + onSearchPressed: onSearchPressed, + onSearchCancelled: onSearchCancelled, + searchController: searchController, + searchFocusNode: searchFocusNode, + onSearchChanged: onSearchChanged, + controller: controller, + ), + Expanded(child: child), + ], + ); + } +} \ No newline at end of file diff --git a/lib/pages/chat_list/tabs/murals_tab.dart b/lib/pages/chat_list/tabs/murals_tab.dart index bc598a7..2e09eff 100644 --- a/lib/pages/chat_list/tabs/murals_tab.dart +++ b/lib/pages/chat_list/tabs/murals_tab.dart @@ -4,43 +4,105 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/pages/chat_list/chat_list.dart'; +import 'package:tatchat/pages/chat_list/chat_list_item.dart'; +import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart'; import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; +import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:tatchat/utils/stream_extension.dart'; +import 'package:tatchat/widgets/matrix.dart'; -class MuralsTab extends StatelessWidget { - const MuralsTab({super.key}); +class MuralsTab extends StatefulWidget { + final ChatListController controller; + + const MuralsTab(this.controller, {super.key}); + + @override + State createState() => _MuralsTabState(); +} + +class _MuralsTabState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + bool _isSearchMode = false; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final l10n = L10n.of(context); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Icon( - Icons.dashboard_outlined, - color: Theme.of(context).colorScheme.primary, + final client = Matrix.of(context).client; + final theme = Theme.of(context); + final searchText = _searchController.text.trim().toLowerCase(); + return ChatTabScaffold( + tab: HomeTab.murals, + isSearchMode: _isSearchMode, + onSearchPressed: () => setState(() => _isSearchMode = true), + onSearchCancelled: () { + setState(() { + _isSearchMode = false; + _searchController.clear(); + }); + }, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: (_) => setState(() {}), + controller: widget.controller, + child: StreamBuilder( + key: ValueKey(client.userID.toString()), + stream: client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, _) { + final murals = client.rooms + .where((room) => room.tags.keys.contains(AppConfig.muralTag)) + .where( + (room) => + searchText.isEmpty || + room + .getLocalizedDisplayname(MatrixLocals(L10n.of(context))) + .toLowerCase() + .contains(searchText), + ) + .toList(); + return ColoredBox( + color: theme.colorScheme.surface, + child: CustomScrollView( + slivers: [ + if (murals.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyTabView( + icon: Icons.dashboard_outlined, + title: L10n.of(context).noMuralsFound, + ), + ) + else + SliverList.builder( + itemCount: murals.length, + itemBuilder: (context, i) { + final room = murals[i]; + return ChatListItem( + room, + key: Key('mural_list_item_${room.id}'), + onTap: () => widget.controller.onChatTap(room), + activeChat: widget.controller.activeChat == room.id, + ); + }, ), - const SizedBox(width: 8), - Text(l10n.murals, style: Theme.of(context).textTheme.titleLarge), - ], - ), + ], ), - ), - SliverFillRemaining( - hasScrollBody: false, - child: EmptyTabView( - icon: Icons.dashboard_outlined, - title: l10n.muralsComingSoon, - ), - ), - ], + ); + }, ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/chat_list/tabs/spaces_tab.dart b/lib/pages/chat_list/tabs/spaces_tab.dart index 050ac84..5c7bbeb 100644 --- a/lib/pages/chat_list/tabs/spaces_tab.dart +++ b/lib/pages/chat_list/tabs/spaces_tab.dart @@ -6,84 +6,181 @@ import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; +import 'package:tatchat/config/app_config.dart'; +import 'package:tatchat/config/themes.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart'; -import 'package:tatchat/pages/chat_list/chat_list_item.dart'; -import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; +import 'package:tatchat/pages/chat_list/navi_rail_item.dart'; +import 'package:tatchat/pages/chat_list/space_view.dart'; +import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/stream_extension.dart'; +import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/matrix.dart'; -class SpacesTab extends StatelessWidget { +class SpacesTab extends StatefulWidget { final ChatListController controller; const SpacesTab(this.controller, {super.key}); + @override + State createState() => _SpacesTabState(); +} + +class _SpacesTabState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + bool _isSearchMode = false; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final client = Matrix.of(context).client; final theme = Theme.of(context); - return StreamBuilder( - key: ValueKey(client.userID.toString()), - stream: client.onSync.stream - .where((s) => s.hasRoomUpdate) - .rateLimit(const Duration(seconds: 1)), - builder: (context, _) { - final spaces = client.rooms.where((room) => room.isSpace).toList(); - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Icon( - Icons.workspaces_outlined, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - L10n.of(context).spaces, - style: theme.textTheme.titleLarge, - ), - ], - ), - ), - ), - if (spaces.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyTabView( - icon: Icons.workspaces_outlined, - title: L10n.of(context).noSpacesFound, - ), - ) - else - SliverList.builder( - itemCount: spaces.length + 1, - itemBuilder: (context, i) { - if (i == spaces.length) { - return ListTile( - leading: const Icon(Icons.add), - title: Text(L10n.of(context).createNewSpace), - onTap: () => context.go('/rooms/newspace'), - ); - } - final space = spaces[i]; - return ChatListItem( - space, - key: Key('spaces_tab_item_${space.id}'), - activeChat: controller.activeSpaceId == space.id, - onTap: () { - controller.setActiveSpace(space.id); - controller.setHomeTab(HomeTab.chats); - }, - ); - }, - ), - ], - ); + final searchText = _searchController.text.trim().toLowerCase(); + return ChatTabScaffold( + tab: HomeTab.spaces, + isSearchMode: _isSearchMode, + onSearchPressed: () => setState(() => _isSearchMode = true), + onSearchCancelled: () { + setState(() { + _isSearchMode = false; + _searchController.clear(); + }); }, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: (_) => setState(() {}), + controller: widget.controller, + child: StreamBuilder( + key: ValueKey(client.userID.toString()), + stream: client.onSync.stream + .where((s) => s.hasRoomUpdate) + .rateLimit(const Duration(seconds: 1)), + builder: (context, _) { + final spaces = client.rooms + .where((room) => room.isSpace) + .where( + (room) => + searchText.isEmpty || + room + .getLocalizedDisplayname(MatrixLocals(L10n.of(context))) + .toLowerCase() + .contains(searchText), + ) + .toList(); + final activeSpaceId = widget.controller.activeSpaceId; + return Row( + children: [ + SizedBox( + width: FluffyThemes.navRailWidth, + child: ColoredBox( + color: theme.colorScheme.surfaceContainerHigh, + child: Column( + children: [ + const SizedBox(height: 8), + if (spaces.isEmpty) + const Expanded(child: SizedBox.shrink()) + else + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 4), + children: [ + for (final space in spaces) + NaviRailItem( + isSelected: activeSpaceId == space.id, + onTap: () => widget.controller.setActiveSpace( + space.id, + ), + toolTip: space.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ), + unreadBadgeFilter: (room) => space + .spaceChildren + .map((c) => c.roomId) + .contains(room.id), + icon: Avatar( + mxContent: space.avatar, + name: space.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ), + shapeBorder: RoundedSuperellipseBorder( + side: BorderSide( + width: 1, + color: theme.dividerColor, + ), + borderRadius: BorderRadius.circular( + AppConfig.spaceBorderRadius, + ), + ), + borderRadius: BorderRadius.circular( + AppConfig.spaceBorderRadius, + ), + ), + ), + ], + ), + ), + NaviRailItem( + isSelected: false, + onTap: () => context.go('/rooms/newspace'), + icon: const Padding( + padding: EdgeInsets.all(12.0), + child: Icon(Icons.add), + ), + toolTip: L10n.of(context).createNewSpace, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + if (FluffyThemes.isColumnMode(context)) + Container(width: 1, color: theme.dividerColor), + Expanded( + child: activeSpaceId == null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.workspaces_outlined, + size: 80, + color: theme.colorScheme.secondary, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + L10n.of(context).selectASpace, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: theme.colorScheme.secondary, + ), + ), + ), + ], + ), + ) + : SpaceView( + key: ValueKey(activeSpaceId), + spaceId: activeSpaceId, + onBack: widget.controller.clearActiveSpace, + onChatTab: widget.controller.onChatTap, + activeChat: widget.controller.activeChat, + ), + ), + ], + ); + }, + ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/intro/intro_page.dart b/lib/pages/intro/intro_page.dart index 1a95ef8..958d8cb 100644 --- a/lib/pages/intro/intro_page.dart +++ b/lib/pages/intro/intro_page.dart @@ -119,8 +119,8 @@ class IntroPage extends StatelessWidget { borderRadius: BorderRadius.circular(128), child: Image.asset( './assets/logo/mini/logo_mini.png', - width: 128, - height: 128, + width: 228, + height: 228, ), ), ), diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index 7aba239..c0fa32f 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; +import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/new_group/new_group_view.dart'; import 'package:tatchat/utils/file_selector.dart'; @@ -127,6 +128,75 @@ class NewGroupController extends State { context.pop(spaceId); } + Future _createCanal() async { + if (!mounted) return; + final client = Matrix.of(context).client; + + final roomId = await client.createGroupChat( + visibility: groupCanBeFound + ? sdk.Visibility.public + : sdk.Visibility.private, + preset: publicGroup + ? sdk.CreateRoomPreset.publicChat + : sdk.CreateRoomPreset.privateChat, + groupName: nameController.text.isNotEmpty ? nameController.text : null, + powerLevelContentOverride: { + // Only admins (power level 100) are allowed to post in a canal. + 'events_default': 100, + }, + initialState: [ + if (avatar != null) + sdk.StateEvent( + type: sdk.EventTypes.RoomAvatar, + content: {'url': avatarUrl.toString()}, + ), + ], + ); + final canal = client.getRoomById(roomId); + if (canal != null) { + await canal.addTag(AppConfig.canalTag); + } + await _addToSpace(roomId); + if (!mounted) return; + + context.go('/rooms/$roomId/invite'); + } + + Future _createMural() async { + if (!mounted) return; + final client = Matrix.of(context).client; + + final roomId = await client.createGroupChat( + visibility: groupCanBeFound + ? sdk.Visibility.public + : sdk.Visibility.private, + preset: publicGroup + ? sdk.CreateRoomPreset.publicChat + : sdk.CreateRoomPreset.privateChat, + groupName: nameController.text.isNotEmpty ? nameController.text : null, + powerLevelContentOverride: publicGroup + ? null + : { + 'events': {MatrixRtcCallMember.eventType: 0}, + }, + initialState: [ + if (avatar != null) + sdk.StateEvent( + type: sdk.EventTypes.RoomAvatar, + content: {'url': avatarUrl.toString()}, + ), + ], + ); + final mural = client.getRoomById(roomId); + if (mural != null) { + await mural.addTag(AppConfig.muralTag); + } + await _addToSpace(roomId); + if (!mounted) return; + + context.go('/rooms/$roomId/invite'); + } + Future _addToSpace(String roomId) async { final spaceId = widget.spaceId; if (spaceId != null) { @@ -164,6 +234,10 @@ class NewGroupController extends State { await _createGroup(); case CreateGroupType.space: await _createSpace(); + case CreateGroupType.canal: + await _createCanal(); + case CreateGroupType.mural: + await _createMural(); } } catch (e, s) { sdk.Logs().d('Unable to create group', e, s); @@ -194,4 +268,4 @@ class NewGroupController extends State { Widget build(BuildContext context) => NewGroupView(this); } -enum CreateGroupType { group, space } +enum CreateGroupType { group, space, canal, mural } diff --git a/lib/pages/new_group/new_group_view.dart b/lib/pages/new_group/new_group_view.dart index 82e1f5e..ac512f6 100644 --- a/lib/pages/new_group/new_group_view.dart +++ b/lib/pages/new_group/new_group_view.dart @@ -16,6 +16,14 @@ class NewGroupView extends StatelessWidget { const NewGroupView(this.controller, {super.key}); + bool get isGroup => controller.createGroupType == CreateGroupType.group; + + bool get isSpace => controller.createGroupType == CreateGroupType.space; + + bool get isCanal => controller.createGroupType == CreateGroupType.canal; + + bool get isMural => controller.createGroupType == CreateGroupType.mural; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -30,9 +38,13 @@ class NewGroupView extends StatelessWidget { ), ), title: Text( - controller.createGroupType == CreateGroupType.space + isSpace ? L10n.of(context).newSpace - : L10n.of(context).createGroup, + : isCanal + ? L10n.of(context).newCanal + : isMural + ? L10n.of(context).newMural + : L10n.of(context).createGroup, ), ), body: MaxWidthBody( @@ -41,19 +53,30 @@ class NewGroupView extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(16.0), - child: SegmentedButton( - selected: {controller.createGroupType}, - onSelectionChanged: controller.setCreateGroupType, - segments: [ - ButtonSegment( - value: CreateGroupType.group, - label: Text(L10n.of(context).group), - ), - ButtonSegment( - value: CreateGroupType.space, - label: Text(L10n.of(context).space), - ), - ], + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + selected: {controller.createGroupType}, + onSelectionChanged: controller.setCreateGroupType, + segments: [ + ButtonSegment( + value: CreateGroupType.group, + label: Text(L10n.of(context).group), + ), + ButtonSegment( + value: CreateGroupType.space, + label: Text(L10n.of(context).space), + ), + ButtonSegment( + value: CreateGroupType.canal, + label: Text(L10n.of(context).canal), + ), + ButtonSegment( + value: CreateGroupType.mural, + label: Text(L10n.of(context).mural), + ), + ], + ), ), ), const SizedBox(height: 16), @@ -85,9 +108,13 @@ class NewGroupView extends StatelessWidget { readOnly: controller.loading, decoration: InputDecoration( prefixIcon: const Icon(Icons.people_outlined), - labelText: controller.createGroupType == CreateGroupType.space + labelText: isSpace ? L10n.of(context).spaceName - : L10n.of(context).groupName, + : isCanal + ? L10n.of(context).canalName + : isMural + ? L10n.of(context).muralName + : L10n.of(context).groupName, ), ), ), @@ -95,11 +122,15 @@ class NewGroupView extends StatelessWidget { SwitchListTile.adaptive( contentPadding: const EdgeInsets.symmetric(horizontal: 32), secondary: const Icon(Icons.public_outlined), - title: Text( - controller.createGroupType == CreateGroupType.space - ? L10n.of(context).spaceIsPublic - : L10n.of(context).groupIsPublic, - ), +title: Text( + isSpace + ? L10n.of(context).spaceIsPublic + : isCanal + ? L10n.of(context).canalIsPublic + : isMural + ? L10n.of(context).muralIsPublic + : L10n.of(context).groupIsPublic, + ), value: controller.publicGroup, onChanged: controller.loading ? null : controller.setPublicGroup, ), @@ -144,7 +175,7 @@ class NewGroupView extends StatelessWidget { AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, - child: controller.createGroupType == CreateGroupType.space + child: !isGroup ? ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 32, @@ -153,7 +184,29 @@ class NewGroupView extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 16.0), child: Icon(Icons.info_outlined), ), - subtitle: Text(L10n.of(context).newSpaceDescription), + subtitle: Text( + isSpace + ? L10n.of(context).newSpaceDescription + : isCanal + ? L10n.of(context).newCanalDescription + : L10n.of(context).newMuralDescription, + ), + ) + : const SizedBox.shrink(), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: isCanal + ? ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 32, + ), + leading: Icon( + Icons.admin_panel_settings_outlined, + color: theme.colorScheme.onSurface, + ), + subtitle: Text(L10n.of(context).canalOnlyAdminCanPost), ) : const SizedBox.shrink(), ), @@ -168,9 +221,15 @@ class NewGroupView extends StatelessWidget { child: controller.loading ? const LinearProgressIndicator() : Text( - controller.createGroupType == CreateGroupType.space + isSpace ? L10n.of(context).createNewSpace - : L10n.of(context).createGroupAndInviteUsers, + : isCanal + ? L10n.of(context).createCanalAndInviteUsers + : isMural + ? L10n.of(context) + .createMuralAndInviteUsers + : L10n.of(context) + .createGroupAndInviteUsers, ), ), ), diff --git a/lib/pages/new_private_chat/new_private_chat_view.dart b/lib/pages/new_private_chat/new_private_chat_view.dart index 083e096..e679818 100644 --- a/lib/pages/new_private_chat/new_private_chat_view.dart +++ b/lib/pages/new_private_chat/new_private_chat_view.dart @@ -147,6 +147,39 @@ class NewPrivateChatView extends StatelessWidget { title: Text(L10n.of(context).createGroup), onTap: () => context.go('/rooms/newgroup'), ), + ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.tertiaryContainer, + foregroundColor: + theme.colorScheme.onTertiaryContainer, + child: const Icon(Icons.campaign_outlined), + ), + title: Text(L10n.of(context).newCanal), + onTap: () => context.go('/rooms/newcanal'), + ), + ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.tertiaryContainer, + foregroundColor: + theme.colorScheme.onTertiaryContainer, + child: const Icon(Icons.workspaces_outlined), + ), + title: Text(L10n.of(context).newSpace), + onTap: () => context.go('/rooms/newspace'), + ), + ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.tertiaryContainer, + foregroundColor: + theme.colorScheme.onTertiaryContainer, + child: const Icon(Icons.dashboard_outlined), + ), + title: Text(L10n.of(context).newMural), + onTap: () => context.go('/rooms/newmural'), + ), if (PlatformInfos.isMobile) ListTile( leading: CircleAvatar(