From 50778d0558495c73055fc76a77f89cd7be19a58d Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Wed, 8 Jan 2025 20:43:42 +0100 Subject: [PATCH] feat: initial commit --- .github/workflows/ci.yaml | 54 + .github/workflows/release.yaml | 18 + .gitignore | 46 + .metadata | 45 + LICENSE | 661 ++++ README.md | 45 + analysis_options.yaml | 18 + assets/nebuchadnezzar.png | Bin 0 -> 28004 bytes assets/sas-emoji.json | 2178 +++++++++++++ l10n.yaml | 5 + lib/app/view/app.dart | 65 + lib/app_config.dart | 8 + .../authentication/authentication_model.dart | 71 + lib/chat/authentication/chat_login_page.dart | 184 ++ lib/chat/bootstrap/bootrap_state_x.dart | 9 + lib/chat/bootstrap/bootstrap_model.dart | 127 + lib/chat/bootstrap/view/bootstrap_page.dart | 455 +++ .../view/key_verification_dialog.dart | 420 +++ lib/chat/chat_download_model.dart | 26 + lib/chat/chat_download_service.dart | 61 + lib/chat/chat_model.dart | 408 +++ lib/chat/data/emojis.dart | 1912 +++++++++++ lib/chat/draft_model.dart | 350 +++ lib/chat/event_x.dart | 20 + lib/chat/local_image_model.dart | 53 + lib/chat/local_image_service.dart | 122 + lib/chat/matrix_file_x.dart | 7 + lib/chat/remote_image_model.dart | 43 + lib/chat/remote_image_service.dart | 49 + lib/chat/room_x.dart | 36 + lib/chat/rooms_filter.dart | 27 + lib/chat/search_model.dart | 148 + lib/chat/view/chat_avatar.dart | 120 + lib/chat/view/chat_image.dart | 222 ++ lib/chat/view/chat_invitation_dialog.dart | 15 + .../chat_all_unread_rooms_badge.dart | 28 + .../chat_master/chat_master_detail_page.dart | 96 + .../chat_master_list_filter_bar.dart | 30 + .../view/chat_master/chat_master_panel.dart | 325 ++ .../chat_master/chat_room_master_tile.dart | 124 + .../view/chat_master/chat_space_filter.dart | 61 + lib/chat/view/chat_profile_dialog.dart | 117 + .../chat_create_or_edit_room_dialog.dart | 430 +++ .../chat_room_create_or_edit_avatar.dart | 113 + .../chat_room_default_background.dart | 113 + .../view/chat_room/chat_room_info_drawer.dart | 192 ++ .../chat_room_info_drawer_topic.dart | 50 + .../chat_room_master_tile_subtitle.dart | 79 + lib/chat/view/chat_room/chat_room_page.dart | 114 + .../view/chat_room/chat_room_users_list.dart | 141 + .../chat_room/chat_seen_by_indicator.dart | 111 + .../view/chat_room/chat_timeline_list.dart | 210 ++ .../view/chat_room/chat_typing_indicator.dart | 131 + .../input/chat_attachment_draft_panel.dart | 68 + .../chat_room/input/chat_emoji_picker.dart | 111 + lib/chat/view/chat_room/input/chat_input.dart | 216 ++ .../input/chat_pending_attachment.dart | 102 + .../chat_room_encryption_status_button.dart | 30 + .../chat_room_join_or_leave_button.dart | 87 + .../titlebar/chat_room_pin_button.dart | 28 + .../titlebar/chat_room_title_bar.dart | 162 + lib/chat/view/chat_start_page.dart | 38 + lib/chat/view/create_room_preset_x.dart | 12 + lib/chat/view/events/chat_event_column.dart | 75 + .../view/events/chat_event_status_icon.dart | 104 + lib/chat/view/events/chat_event_tile.dart | 75 + lib/chat/view/events/chat_html_message.dart | 327 ++ .../chat_message_attachment_indicator.dart | 43 + lib/chat/view/events/chat_message_badge.dart | 50 + lib/chat/view/events/chat_message_bubble.dart | 223 ++ .../events/chat_message_bubble_shape.dart | 33 + ...chat_message_image_full_screen_dialog.dart | 67 + .../events/chat_message_media_avatar.dart | 47 + lib/chat/view/events/chat_message_menu.dart | 134 + .../view/events/chat_message_reactions.dart | 227 ++ .../events/chat_message_reply_header.dart | 104 + .../events/localized_display_event_text.dart | 98 + lib/chat/view/mxc_image.dart | 91 + lib/chat/view/no_selected_room_page.dart | 32 + lib/chat/view/search_auto_complete.dart | 248 ++ lib/chat/view/side_bar_button.dart | 23 + lib/common/date_time_x.dart | 40 + lib/common/logging.dart | 10 + lib/common/view/build_context_x.dart | 12 + lib/common/view/circle_wave_loader.dart | 32 + lib/common/view/common_widgets.dart | 164 + lib/common/view/confirm.dart | 78 + lib/common/view/image_shimmer.dart | 26 + lib/common/view/safe_network_image.dart | 118 + lib/common/view/scaffold_state_x.dart | 8 + lib/common/view/sliver_sticky_panel.dart | 43 + lib/common/view/snackbars.dart | 70 + lib/common/view/space.dart | 35 + lib/common/view/theme.dart | 79 + lib/common/view/ui_constants.dart | 33 + lib/constants.dart | 4 + lib/l10n/app_de.arb | 2788 +++++++++++++++++ lib/l10n/app_en.arb | 2788 +++++++++++++++++ lib/l10n/l10n.dart | 13 + lib/main.dart | 29 + lib/register.dart | 149 + linux/.gitignore | 1 + linux/CMakeLists.txt | 147 + linux/flutter/CMakeLists.txt | 88 + linux/flutter/generated_plugin_registrant.cc | 55 + linux/flutter/generated_plugin_registrant.h | 15 + linux/flutter/generated_plugins.cmake | 34 + linux/main.cc | 6 + linux/my_application.cc | 94 + linux/my_application.h | 18 + macos/.gitignore | 7 + macos/Flutter/Flutter-Debug.xcconfig | 2 + macos/Flutter/Flutter-Release.xcconfig | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 38 + macos/Podfile | 46 + macos/Podfile.lock | 103 + macos/Runner.xcodeproj/project.pbxproj | 805 +++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 + .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 + .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 77746 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5692 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 850 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 12188 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1445 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 29153 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2782 bytes macos/Runner/Base.lproj/MainMenu.xib | 343 ++ macos/Runner/Configs/AppInfo.xcconfig | 14 + macos/Runner/Configs/Debug.xcconfig | 2 + macos/Runner/Configs/Release.xcconfig | 2 + macos/Runner/Configs/Warnings.xcconfig | 13 + macos/Runner/DebugProfile.entitlements | 16 + macos/Runner/Info.plist | 32 + macos/Runner/MainFlutterWindow.swift | 15 + macos/Runner/Release.entitlements | 14 + macos/RunnerTests/RunnerTests.swift | 12 + needs_translation.json | 1 + pubspec.lock | 1741 ++++++++++ pubspec.yaml | 84 + test/widget_test.dart | 11 + 144 files changed, 23833 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 assets/nebuchadnezzar.png create mode 100644 assets/sas-emoji.json create mode 100644 l10n.yaml create mode 100644 lib/app/view/app.dart create mode 100644 lib/app_config.dart create mode 100644 lib/chat/authentication/authentication_model.dart create mode 100644 lib/chat/authentication/chat_login_page.dart create mode 100644 lib/chat/bootstrap/bootrap_state_x.dart create mode 100644 lib/chat/bootstrap/bootstrap_model.dart create mode 100644 lib/chat/bootstrap/view/bootstrap_page.dart create mode 100644 lib/chat/bootstrap/view/key_verification_dialog.dart create mode 100644 lib/chat/chat_download_model.dart create mode 100644 lib/chat/chat_download_service.dart create mode 100644 lib/chat/chat_model.dart create mode 100644 lib/chat/data/emojis.dart create mode 100644 lib/chat/draft_model.dart create mode 100644 lib/chat/event_x.dart create mode 100644 lib/chat/local_image_model.dart create mode 100644 lib/chat/local_image_service.dart create mode 100644 lib/chat/matrix_file_x.dart create mode 100644 lib/chat/remote_image_model.dart create mode 100644 lib/chat/remote_image_service.dart create mode 100644 lib/chat/room_x.dart create mode 100644 lib/chat/rooms_filter.dart create mode 100644 lib/chat/search_model.dart create mode 100644 lib/chat/view/chat_avatar.dart create mode 100644 lib/chat/view/chat_image.dart create mode 100644 lib/chat/view/chat_invitation_dialog.dart create mode 100644 lib/chat/view/chat_master/chat_all_unread_rooms_badge.dart create mode 100644 lib/chat/view/chat_master/chat_master_detail_page.dart create mode 100644 lib/chat/view/chat_master/chat_master_list_filter_bar.dart create mode 100644 lib/chat/view/chat_master/chat_master_panel.dart create mode 100644 lib/chat/view/chat_master/chat_room_master_tile.dart create mode 100644 lib/chat/view/chat_master/chat_space_filter.dart create mode 100644 lib/chat/view/chat_profile_dialog.dart create mode 100644 lib/chat/view/chat_room/chat_create_or_edit_room_dialog.dart create mode 100644 lib/chat/view/chat_room/chat_room_create_or_edit_avatar.dart create mode 100644 lib/chat/view/chat_room/chat_room_default_background.dart create mode 100644 lib/chat/view/chat_room/chat_room_info_drawer.dart create mode 100644 lib/chat/view/chat_room/chat_room_info_drawer_topic.dart create mode 100644 lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart create mode 100644 lib/chat/view/chat_room/chat_room_page.dart create mode 100644 lib/chat/view/chat_room/chat_room_users_list.dart create mode 100644 lib/chat/view/chat_room/chat_seen_by_indicator.dart create mode 100644 lib/chat/view/chat_room/chat_timeline_list.dart create mode 100644 lib/chat/view/chat_room/chat_typing_indicator.dart create mode 100644 lib/chat/view/chat_room/input/chat_attachment_draft_panel.dart create mode 100644 lib/chat/view/chat_room/input/chat_emoji_picker.dart create mode 100644 lib/chat/view/chat_room/input/chat_input.dart create mode 100644 lib/chat/view/chat_room/input/chat_pending_attachment.dart create mode 100644 lib/chat/view/chat_room/titlebar/chat_room_encryption_status_button.dart create mode 100644 lib/chat/view/chat_room/titlebar/chat_room_join_or_leave_button.dart create mode 100644 lib/chat/view/chat_room/titlebar/chat_room_pin_button.dart create mode 100644 lib/chat/view/chat_room/titlebar/chat_room_title_bar.dart create mode 100644 lib/chat/view/chat_start_page.dart create mode 100644 lib/chat/view/create_room_preset_x.dart create mode 100644 lib/chat/view/events/chat_event_column.dart create mode 100644 lib/chat/view/events/chat_event_status_icon.dart create mode 100644 lib/chat/view/events/chat_event_tile.dart create mode 100644 lib/chat/view/events/chat_html_message.dart create mode 100644 lib/chat/view/events/chat_message_attachment_indicator.dart create mode 100644 lib/chat/view/events/chat_message_badge.dart create mode 100644 lib/chat/view/events/chat_message_bubble.dart create mode 100644 lib/chat/view/events/chat_message_bubble_shape.dart create mode 100644 lib/chat/view/events/chat_message_image_full_screen_dialog.dart create mode 100644 lib/chat/view/events/chat_message_media_avatar.dart create mode 100644 lib/chat/view/events/chat_message_menu.dart create mode 100644 lib/chat/view/events/chat_message_reactions.dart create mode 100644 lib/chat/view/events/chat_message_reply_header.dart create mode 100644 lib/chat/view/events/localized_display_event_text.dart create mode 100644 lib/chat/view/mxc_image.dart create mode 100644 lib/chat/view/no_selected_room_page.dart create mode 100644 lib/chat/view/search_auto_complete.dart create mode 100644 lib/chat/view/side_bar_button.dart create mode 100644 lib/common/date_time_x.dart create mode 100644 lib/common/logging.dart create mode 100644 lib/common/view/build_context_x.dart create mode 100644 lib/common/view/circle_wave_loader.dart create mode 100644 lib/common/view/common_widgets.dart create mode 100644 lib/common/view/confirm.dart create mode 100644 lib/common/view/image_shimmer.dart create mode 100644 lib/common/view/safe_network_image.dart create mode 100644 lib/common/view/scaffold_state_x.dart create mode 100644 lib/common/view/sliver_sticky_panel.dart create mode 100644 lib/common/view/snackbars.dart create mode 100644 lib/common/view/space.dart create mode 100644 lib/common/view/theme.dart create mode 100644 lib/common/view/ui_constants.dart create mode 100644 lib/constants.dart create mode 100644 lib/l10n/app_de.arb create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/l10n.dart create mode 100644 lib/main.dart create mode 100644 lib/register.dart create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/flutter/generated_plugin_registrant.cc create mode 100644 linux/flutter/generated_plugin_registrant.h create mode 100644 linux/flutter/generated_plugins.cmake create mode 100644 linux/main.cc create mode 100644 linux/my_application.cc create mode 100644 linux/my_application.h create mode 100644 macos/.gitignore create mode 100644 macos/Flutter/Flutter-Debug.xcconfig create mode 100644 macos/Flutter/Flutter-Release.xcconfig create mode 100644 macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 macos/Podfile create mode 100644 macos/Podfile.lock create mode 100644 macos/Runner.xcodeproj/project.pbxproj create mode 100644 macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner/AppDelegate.swift create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 macos/Runner/Base.lproj/MainMenu.xib create mode 100644 macos/Runner/Configs/AppInfo.xcconfig create mode 100644 macos/Runner/Configs/Debug.xcconfig create mode 100644 macos/Runner/Configs/Release.xcconfig create mode 100644 macos/Runner/Configs/Warnings.xcconfig create mode 100644 macos/Runner/DebugProfile.entitlements create mode 100644 macos/Runner/Info.plist create mode 100644 macos/Runner/MainFlutterWindow.swift create mode 100644 macos/Runner/Release.entitlements create mode 100644 macos/RunnerTests/RunnerTests.swift create mode 100644 needs_translation.json create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..006aed5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + branches: [main] + +env: + FLUTTER_VERSION: '3.27.1' + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{env.FLUTTER_VERSION}} + - run: flutter pub get + - run: flutter analyze --fatal-infos + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{env.FLUTTER_VERSION}} + - run: flutter pub get + - run: dart format --set-exit-if-changed . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{env.FLUTTER_VERSION}} + - run: flutter test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{env.FLUTTER_VERSION}} + - run: sudo apt update + - run: sudo apt install -y clang cmake curl libgtk-3-dev ninja-build pkg-config unzip libunwind-dev libmpv-dev libsqlite3-dev libolm-dev libolm3 libcrypto++-dev libsecret-1-dev libjsoncpp-dev + - run: flutter pub get + - run: flutter build linux -v \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b96ebf8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,18 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + release: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: dart \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..039e720 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release +.vscode/settings.json diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..be53bfe --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "b0850beeb25f6d5b10426284f506557f66181b36" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: android + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: ios + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: linux + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: macos + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: web + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + - platform: windows + create_revision: b0850beeb25f6d5b10426284f506557f66181b36 + base_revision: b0850beeb25f6d5b10426284f506557f66181b36 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..096ad99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + famedlySDK + Copyright (C) 2019 famedly + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8110bb --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Nebuchadnezzar - Matrix Client written in Dart & Flutter for Linux & MacOS + +## Linux setup + +``` +sudo apt install libsqlite3-dev libolm-dev libolm3 libcrypto++-dev libsecret-1-dev libjsoncpp-dev +``` + +snap TODO: +```yaml +parts: + uet-lms: + source: . + plugin: flutter + flutter-target: lib/main.dart + build-packages: + - libsecret-1-dev + - libjsoncpp-dev + stage-packages: + - libsecret-1-0 + - libjsoncpp-dev +``` + +## macos setup + +Assuming your project lies in "~/Projects/nebuchadnezzar" + +This needs a better building, but it works for now :P + +``` +brew install libolm +cp /opt/homebrew/Cellar/libolm/3.2.16/lib/libolm.3.2.16.dylib ~/Projects/nebuchadnezzar/build/macos/Build/Products/Debug/nebuchadnezzar.app/Contents/Frameworks +cp /opt/homebrew/Cellar/libolm/3.2.16/lib/libolm.dylib ~/Projects/nebuchadnezzar/build/macos/Build/Products/Debug/nebuchadnezzar.app/Contents/Frameworks +cp /opt/homebrew/Cellar/libolm/3.2.16/lib/libolm.3.dylib ~/Projects/nebuchadnezzar/build/macos/Build/Products/Debug/nebuchadnezzar.app/Contents/Frameworks +cp /Users/frederik/Downloads/libcrypto.1.1.dylib ~/Projects/nebuchadnezzar/build/macos/Build/Products/Debug/nebuchadnezzar.app/Contents/Frameworks +``` + +### Credits: Fluffy-Chat + +The bootstrap UI, the HTML message and the english translations are copied from the fluffy-chat repository. +This will be replaced soon. Thank you for your awesome dart sdk! + +### Why this name? + +The name is inspired by the traveling vehicle from the movie Matrix, which uses the name of https://en.wikipedia.org/wiki/Nebuchadnezzar_II the second king of Babylon! \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1ee12d1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,18 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_print: true + cancel_subscriptions: true + prefer_single_quotes: true + prefer_const_constructors: true + prefer_const_declarations: true + require_trailing_commas: true + use_super_parameters: true + close_sinks: true + prefer_relative_imports: true + sort_pub_dependencies: true + unnecessary_parenthesis: true + unnecessary_await_in_return: true + unnecessary_late: true + unnecessary_breaks: true \ No newline at end of file diff --git a/assets/nebuchadnezzar.png b/assets/nebuchadnezzar.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c9c24eee722e5a52caeb6acae3971b39ea60d2 GIT binary patch literal 28004 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 V44BJ50p}Qyv4Pp?LOt8C{{t&G#d!b# literal 0 HcmV?d00001 diff --git a/assets/sas-emoji.json b/assets/sas-emoji.json new file mode 100644 index 0000000..8f76e99 --- /dev/null +++ b/assets/sas-emoji.json @@ -0,0 +1,2178 @@ +[ + { + "number": 0, + "emoji": "🐶", + "description": "Dog", + "unicode": "U+1F436", + "translated_descriptions": { + "ar": "كَلب", + "bg": "Куче", + "ca": "Gos", + "cs": "Pes", + "de": "Hund", + "eo": "Hundo", + "es": "Perro", + "et": "Koer", + "fi": "Koira", + "fr": "Chien", + "hr": "pas", + "hu": "Kutya", + "it": "Cane", + "ja": "犬", + "nb_NO": "Hund", + "nl": "Hond", + "pt_BR": "Cachorro", + "ru": "Собака", + "si": "බල්ලා", + "sk": "Hlava psa", + "sr": "пас", + "sv": "Hund", + "szl": null, + "tzm": "Aydi", + "uk": "Пес", + "zh_Hans": "狗" + } + }, + { + "number": 1, + "emoji": "🐱", + "description": "Cat", + "unicode": "U+1F431", + "translated_descriptions": { + "ar": "هِرَّة", + "bg": "Котка", + "ca": "Gat", + "cs": "Kočka", + "de": "Katze", + "eo": "Kato", + "es": "Gato", + "et": "Kass", + "fi": "Kissa", + "fr": "Chat", + "hr": "mačka", + "hu": "Macska", + "it": "Gatto", + "ja": "猫", + "nb_NO": "Katt", + "nl": "Kat", + "pt_BR": "Gato", + "ru": "Кошка", + "si": "පූසා", + "sk": "Hlava mačky", + "sr": "мачка", + "sv": "Katt", + "szl": null, + "tzm": "Amuc", + "uk": "Кіт", + "zh_Hans": "猫" + } + }, + { + "number": 2, + "emoji": "🦁", + "description": "Lion", + "unicode": "U+1F981", + "translated_descriptions": { + "ar": "أَسَد", + "bg": "Лъв", + "ca": "Lleó", + "cs": "Lev", + "de": "Löwe", + "eo": "Leono", + "es": "León", + "et": "Lõvi", + "fi": "Leijona", + "fr": "Lion", + "hr": "lav", + "hu": "Oroszlán", + "it": "Leone", + "ja": "ライオン", + "nb_NO": "Løve", + "nl": "Leeuw", + "pt_BR": "Leão", + "ru": "Лев", + "si": "සිංහයා", + "sk": "Hlava leva", + "sr": "лав", + "sv": "Lejon", + "szl": null, + "tzm": "Izem", + "uk": "Лев", + "zh_Hans": "狮子" + } + }, + { + "number": 3, + "emoji": "🐎", + "description": "Horse", + "unicode": "U+1F40E", + "translated_descriptions": { + "ar": "حِصَان", + "bg": "Кон", + "ca": "Cavall", + "cs": "Kůň", + "de": "Pferd", + "eo": "Ĉevalo", + "es": "Caballo", + "et": "Hobune", + "fi": "Hevonen", + "fr": "Cheval", + "hr": "konj", + "hu": "Ló", + "it": "Cavallo", + "ja": "馬", + "nb_NO": "Hest", + "nl": "Paard", + "pt_BR": "Cavalo", + "ru": "Лошадь", + "si": "අශ්වයා", + "sk": "Kôň", + "sr": "коњ", + "sv": "Häst", + "szl": null, + "tzm": "Ayyis", + "uk": "Кінь", + "zh_Hans": "马" + } + }, + { + "number": 4, + "emoji": "🦄", + "description": "Unicorn", + "unicode": "U+1F984", + "translated_descriptions": { + "ar": "حِصَانٌ بِقَرن", + "bg": "Еднорог", + "ca": "Unicorn", + "cs": "Jednorožec", + "de": "Einhorn", + "eo": "Unukorno", + "es": "Unicornio", + "et": "Ükssarvik", + "fi": "Yksisarvinen", + "fr": "Licorne", + "hr": "jednorog", + "hu": "Egyszarvú", + "it": "Unicorno", + "ja": "ユニコーン", + "nb_NO": "Enhjørning", + "nl": "Eenhoorn", + "pt_BR": "Unicórnio", + "ru": "Единорог", + "si": null, + "sk": "Hlava jednorožca", + "sr": "једнорог", + "sv": "Enhörning", + "szl": null, + "tzm": null, + "uk": "Єдиноріг", + "zh_Hans": "独角兽" + } + }, + { + "number": 5, + "emoji": "🐷", + "description": "Pig", + "unicode": "U+1F437", + "translated_descriptions": { + "ar": "خِنزِير", + "bg": "Прасе", + "ca": "Porc", + "cs": "Prase", + "de": "Schwein", + "eo": "Porko", + "es": "Cerdo", + "et": "Siga", + "fi": "Sika", + "fr": "Cochon", + "hr": "svinja", + "hu": "Malac", + "it": "Maiale", + "ja": "ブタ", + "nb_NO": "Gris", + "nl": "Varken", + "pt_BR": "Porco", + "ru": "Свинья", + "si": null, + "sk": "Hlava prasaťa", + "sr": "прасе", + "sv": "Gris", + "szl": null, + "tzm": "Ilef", + "uk": "Свиня", + "zh_Hans": "猪" + } + }, + { + "number": 6, + "emoji": "🐘", + "description": "Elephant", + "unicode": "U+1F418", + "translated_descriptions": { + "ar": "فِيل", + "bg": "Слон", + "ca": "Elefant", + "cs": "Slon", + "de": "Elefant", + "eo": "Elefanto", + "es": "Elefante", + "et": "Elevant", + "fi": "Norsu", + "fr": "Éléphant", + "hr": "slon", + "hu": "Elefánt", + "it": "Elefante", + "ja": "ゾウ", + "nb_NO": "Elefant", + "nl": "Olifant", + "pt_BR": "Elefante", + "ru": "Слон", + "si": null, + "sk": "Slon", + "sr": "слон", + "sv": "Elefant", + "szl": null, + "tzm": "Ilu", + "uk": "Слон", + "zh_Hans": "大象" + } + }, + { + "number": 7, + "emoji": "🐰", + "description": "Rabbit", + "unicode": "U+1F430", + "translated_descriptions": { + "ar": "أَرنَب", + "bg": "Заек", + "ca": "Conill", + "cs": "Králík", + "de": "Hase", + "eo": "Kuniklo", + "es": "Conejo", + "et": "Jänes", + "fi": "Kani", + "fr": "Lapin", + "hr": "zec", + "hu": "Nyúl", + "it": "Coniglio", + "ja": "うさぎ", + "nb_NO": "Kanin", + "nl": "Konijn", + "pt_BR": "Coelho", + "ru": "Кролик", + "si": null, + "sk": "Hlava zajaca", + "sr": "зец", + "sv": "Kanin", + "szl": null, + "tzm": "Agnin", + "uk": "Кріль", + "zh_Hans": "兔子" + } + }, + { + "number": 8, + "emoji": "🐼", + "description": "Panda", + "unicode": "U+1F43C", + "translated_descriptions": { + "ar": "باندَا", + "bg": "Панда", + "ca": "Panda", + "cs": "Panda", + "de": "Panda", + "eo": "Pando", + "es": "Panda", + "et": "Panda", + "fi": "Panda", + "fr": "Panda", + "hr": "panda", + "hu": "Panda", + "it": "Panda", + "ja": "パンダ", + "nb_NO": "Panda", + "nl": "Panda", + "pt_BR": "Panda", + "ru": "Панда", + "si": null, + "sk": "Hlava pandy", + "sr": "панда", + "sv": "Panda", + "szl": null, + "tzm": null, + "uk": "Панда", + "zh_Hans": "熊猫" + } + }, + { + "number": 9, + "emoji": "🐓", + "description": "Rooster", + "unicode": "U+1F413", + "translated_descriptions": { + "ar": "دِيك", + "bg": "Петел", + "ca": "Gall", + "cs": "Kohout", + "de": "Hahn", + "eo": "Virkoko", + "es": "Gallo", + "et": "Kukk", + "fi": "Kukko", + "fr": "Coq", + "hr": "kokot", + "hu": "Kakas", + "it": "Gallo", + "ja": "ニワトリ", + "nb_NO": "Hane", + "nl": "Haan", + "pt_BR": "Galo", + "ru": "Петух", + "si": null, + "sk": "Kohút", + "sr": "петао", + "sv": "Tupp", + "szl": null, + "tzm": "Ayaẓiḍ", + "uk": "Когут", + "zh_Hans": "公鸡" + } + }, + { + "number": 10, + "emoji": "🐧", + "description": "Penguin", + "unicode": "U+1F427", + "translated_descriptions": { + "ar": "بِطريق", + "bg": "Пингвин", + "ca": "Pingüí", + "cs": "Tučňák", + "de": "Pinguin", + "eo": "Pingveno", + "es": "Pingüino", + "et": "Pingviin", + "fi": "Pingviini", + "fr": "Manchot", + "hr": "pingvin", + "hu": "Pingvin", + "it": "Pinguino", + "ja": "ペンギン", + "nb_NO": "Pingvin", + "nl": "Pinguïn", + "pt_BR": "Pinguim", + "ru": "Пингвин", + "si": null, + "sk": "Tučniak", + "sr": "пингвин", + "sv": "Pingvin", + "szl": null, + "tzm": null, + "uk": "Пінгвін", + "zh_Hans": "企鹅" + } + }, + { + "number": 11, + "emoji": "🐢", + "description": "Turtle", + "unicode": "U+1F422", + "translated_descriptions": { + "ar": "سُلحفاة", + "bg": "Костенурка", + "ca": "Tortuga", + "cs": "Želva", + "de": "Schildkröte", + "eo": "Testudo", + "es": "Tortuga", + "et": "Kilpkonn", + "fi": "Kilpikonna", + "fr": "Tortue", + "hr": "kornjača", + "hu": "Teknős", + "it": "Tartaruga", + "ja": "亀", + "nb_NO": "Skilpadde", + "nl": "Schildpad", + "pt_BR": "Tartaruga", + "ru": "Черепаха", + "si": null, + "sk": "Korytnačka", + "sr": "корњача", + "sv": "Sköldpadda", + "szl": null, + "tzm": "Ifker", + "uk": "Черепаха", + "zh_Hans": "乌龟" + } + }, + { + "number": 12, + "emoji": "🐟", + "description": "Fish", + "unicode": "U+1F41F", + "translated_descriptions": { + "ar": "سَمَكَة", + "bg": "Риба", + "ca": "Peix", + "cs": "Ryba", + "de": "Fisch", + "eo": "Fiŝo", + "es": "Pez", + "et": "Kala", + "fi": "Kala", + "fr": "Poisson", + "hr": "riba", + "hu": "Hal", + "it": "Pesce", + "ja": "魚", + "nb_NO": "Fisk", + "nl": "Vis", + "pt_BR": "Peixe", + "ru": "Рыба", + "si": null, + "sk": "Ryba", + "sr": "риба", + "sv": "Fisk", + "szl": null, + "tzm": "Aselm", + "uk": "Риба", + "zh_Hans": "鱼" + } + }, + { + "number": 13, + "emoji": "🐙", + "description": "Octopus", + "unicode": "U+1F419", + "translated_descriptions": { + "ar": "أُخطُبُوط", + "bg": "Октопод", + "ca": "Pop", + "cs": "Chobotnice", + "de": "Oktopus", + "eo": "Polpo", + "es": "Pulpo", + "et": "Kaheksajalg", + "fi": "Tursas", + "fr": "Poulpe", + "hr": "hobotnica", + "hu": "Polip", + "it": "Polpo", + "ja": "たこ", + "nb_NO": "Blekksprut", + "nl": "Octopus", + "pt_BR": "Polvo", + "ru": "Осьминог", + "si": null, + "sk": "Chobotnica", + "sr": "октопод", + "sv": "Bläckfisk", + "szl": null, + "tzm": null, + "uk": "Восьминіг", + "zh_Hans": "章鱼" + } + }, + { + "number": 14, + "emoji": "🦋", + "description": "Butterfly", + "unicode": "U+1F98B", + "translated_descriptions": { + "ar": "فَرَاشَة", + "bg": "Пеперуда", + "ca": "Papallona", + "cs": "Motýl", + "de": "Schmetterling", + "eo": "Papilio", + "es": "Mariposa", + "et": "Liblikas", + "fi": "Perhonen", + "fr": "Papillon", + "hr": "leptir", + "hu": "Pillangó", + "it": "Farfalla", + "ja": "ちょうちょ", + "nb_NO": "Sommerfugl", + "nl": "Vlinder", + "pt_BR": "Borboleta", + "ru": "Бабочка", + "si": null, + "sk": "Motýľ", + "sr": "лептир", + "sv": "Fjäril", + "szl": null, + "tzm": null, + "uk": "Метелик", + "zh_Hans": "蝴蝶" + } + }, + { + "number": 15, + "emoji": "🌷", + "description": "Flower", + "unicode": "U+1F337", + "translated_descriptions": { + "ar": "زَهرَة", + "bg": "Цвете", + "ca": "Flor", + "cs": "Květina", + "de": "Blume", + "eo": "Floro", + "es": "Flor", + "et": "Lill", + "fi": "Kukka", + "fr": "Fleur", + "hr": "svijet", + "hu": "Virág", + "it": "Fiore", + "ja": "花", + "nb_NO": "Blomst", + "nl": "Bloem", + "pt_BR": "Flor", + "ru": "Цветок", + "si": null, + "sk": "Tulipán", + "sr": "цвет", + "sv": "Blomma", + "szl": null, + "tzm": null, + "uk": "Квітка", + "zh_Hans": "花" + } + }, + { + "number": 16, + "emoji": "🌳", + "description": "Tree", + "unicode": "U+1F333", + "translated_descriptions": { + "ar": "شَجَرَة", + "bg": "Дърво", + "ca": "Arbre", + "cs": "Strom", + "de": "Baum", + "eo": "Arbo", + "es": "Árbol", + "et": "Puu", + "fi": "Puu", + "fr": "Arbre", + "hr": "drvo", + "hu": "Fa", + "it": "Albero", + "ja": "木", + "nb_NO": "Tre", + "nl": "Boom", + "pt_BR": "Árvore", + "ru": "Дерево", + "si": null, + "sk": "Listnatý strom", + "sr": "дрво", + "sv": "Träd", + "szl": null, + "tzm": "Aseklu", + "uk": "Дерево", + "zh_Hans": "树" + } + }, + { + "number": 17, + "emoji": "🌵", + "description": "Cactus", + "unicode": "U+1F335", + "translated_descriptions": { + "ar": "صبار", + "bg": "Кактус", + "ca": "Cactus", + "cs": "Kaktus", + "de": "Kaktus", + "eo": "Kakto", + "es": "Cactus", + "et": "Kaktus", + "fi": "Kaktus", + "fr": "Cactus", + "hr": "kaktus", + "hu": "Kaktusz", + "it": "Cactus", + "ja": "サボテン", + "nb_NO": "Kaktus", + "nl": "Cactus", + "pt_BR": "Cacto", + "ru": "Кактус", + "si": null, + "sk": "Kaktus", + "sr": "кактус", + "sv": "Kaktus", + "szl": null, + "tzm": null, + "uk": "Кактус", + "zh_Hans": "仙人掌" + } + }, + { + "number": 18, + "emoji": "🍄", + "description": "Mushroom", + "unicode": "U+1F344", + "translated_descriptions": { + "ar": "فُطر", + "bg": "Гъба", + "ca": "Bolet", + "cs": "Houba", + "de": "Pilz", + "eo": "Fungo", + "es": "Seta", + "et": "Seen", + "fi": "Sieni", + "fr": "Champignon", + "hr": "gljiva", + "hu": "Gomba", + "it": "Fungo", + "ja": "きのこ", + "nb_NO": "Sopp", + "nl": "Paddenstoel", + "pt_BR": "Cogumelo", + "ru": "Гриб", + "si": null, + "sk": "Huba", + "sr": "печурка", + "sv": "Svamp", + "szl": null, + "tzm": "Agursel", + "uk": "Гриб", + "zh_Hans": "蘑菇" + } + }, + { + "number": 19, + "emoji": "🌏", + "description": "Globe", + "unicode": "U+1F30F", + "translated_descriptions": { + "ar": "كُرَةٌ أرضِيَّة", + "bg": "Глобус", + "ca": "Globus terraqüi", + "cs": "Zeměkoule", + "de": "Globus", + "eo": "Globo", + "es": "Globo", + "et": "Maakera", + "fi": "Maapallo", + "fr": "Globe", + "hr": "Globus", + "hu": "Földgömb", + "it": "Globo", + "ja": "地球", + "nb_NO": "Globus", + "nl": "Wereldbol", + "pt_BR": "Globo", + "ru": "Глобус", + "si": null, + "sk": "Zemeguľa", + "sr": "глобус", + "sv": "Jordklot", + "szl": null, + "tzm": null, + "uk": "Глобус", + "zh_Hans": "地球" + } + }, + { + "number": 20, + "emoji": "🌙", + "description": "Moon", + "unicode": "U+1F319", + "translated_descriptions": { + "ar": "قَمَر", + "bg": "Луна", + "ca": "Lluna", + "cs": "Měsíc", + "de": "Mond", + "eo": "Luno", + "es": "Luna", + "et": "Kuu", + "fi": "Kuu", + "fr": "Lune", + "hr": "mjesec", + "hu": "Hold", + "it": "Luna", + "ja": "月", + "nb_NO": "Måne", + "nl": "Maan", + "pt_BR": "Lua", + "ru": "Луна", + "si": null, + "sk": "Polmesiac", + "sr": "месец", + "sv": "Måne", + "szl": null, + "tzm": "Ayyur", + "uk": "Місяць", + "zh_Hans": "月亮" + } + }, + { + "number": 21, + "emoji": "☁️", + "description": "Cloud", + "unicode": "U+2601U+FE0F", + "translated_descriptions": { + "ar": "سَحابَة", + "bg": "Облак", + "ca": "Núvol", + "cs": "Mrak", + "de": "Wolke", + "eo": "Nubo", + "es": "Nube", + "et": "Pilv", + "fi": "Pilvi", + "fr": "Nuage", + "hr": "oblak", + "hu": "Felhő", + "it": "Nuvola", + "ja": "雲", + "nb_NO": "Sky", + "nl": "Wolk", + "pt_BR": "Nuvem", + "ru": "Облако", + "si": null, + "sk": "Oblak", + "sr": "облак", + "sv": "Moln", + "szl": null, + "tzm": null, + "uk": "Хмара", + "zh_Hans": "云" + } + }, + { + "number": 22, + "emoji": "🔥", + "description": "Fire", + "unicode": "U+1F525", + "translated_descriptions": { + "ar": "نار", + "bg": "Огън", + "ca": "Foc", + "cs": "Oheň", + "de": "Feuer", + "eo": "Fajro", + "es": "Fuego", + "et": "Tuli", + "fi": "Tuli", + "fr": "Feu", + "hr": "vatra", + "hu": "Tűz", + "it": "Fuoco", + "ja": "炎", + "nb_NO": "Flamme", + "nl": "Vuur", + "pt_BR": "Fogo", + "ru": "Огонь", + "si": null, + "sk": "Oheň", + "sr": "ватра", + "sv": "Eld", + "szl": null, + "tzm": "Timessi", + "uk": "Вогонь", + "zh_Hans": "火" + } + }, + { + "number": 23, + "emoji": "🍌", + "description": "Banana", + "unicode": "U+1F34C", + "translated_descriptions": { + "ar": "مَوزَة", + "bg": "Банан", + "ca": "Plàtan", + "cs": "Banán", + "de": "Banane", + "eo": "Banano", + "es": "Plátano", + "et": "Banaan", + "fi": "Banaani", + "fr": "Banane", + "hr": "banana", + "hu": "Banán", + "it": "Banana", + "ja": "バナナ", + "nb_NO": "Banan", + "nl": "Banaan", + "pt_BR": "Banana", + "ru": "Банан", + "si": null, + "sk": "Banán", + "sr": "банана", + "sv": "Banan", + "szl": null, + "tzm": "Tabanant", + "uk": "Банан", + "zh_Hans": "香蕉" + } + }, + { + "number": 24, + "emoji": "🍎", + "description": "Apple", + "unicode": "U+1F34E", + "translated_descriptions": { + "ar": "تُفَّاحَة", + "bg": "Ябълка", + "ca": "Poma", + "cs": "Jablko", + "de": "Apfel", + "eo": "Pomo", + "es": "Manzana", + "et": "Õun", + "fi": "Omena", + "fr": "Pomme", + "hr": "jabuka", + "hu": "Alma", + "it": "Mela", + "ja": "リンゴ", + "nb_NO": "Eple", + "nl": "Appel", + "pt_BR": "Maçã", + "ru": "Яблоко", + "si": null, + "sk": "Červené jablko", + "sr": "јабука", + "sv": "Äpple", + "szl": null, + "tzm": "Tadeffuyt", + "uk": "Яблуко", + "zh_Hans": "苹果" + } + }, + { + "number": 25, + "emoji": "🍓", + "description": "Strawberry", + "unicode": "U+1F353", + "translated_descriptions": { + "ar": "فَراوِلَة", + "bg": "Ягода", + "ca": "Maduixa", + "cs": "Jahoda", + "de": "Erdbeere", + "eo": "Frago", + "es": "Fresa", + "et": "Maasikas", + "fi": "Mansikka", + "fr": "Fraise", + "hr": "jagoda", + "hu": "Eper", + "it": "Fragola", + "ja": "いちご", + "nb_NO": "Jordbær", + "nl": "Aardbei", + "pt_BR": "Morango", + "ru": "Клубника", + "si": null, + "sk": "Jahoda", + "sr": "јагода", + "sv": "Jordgubbe", + "szl": null, + "tzm": null, + "uk": "Полуниця", + "zh_Hans": "草莓" + } + }, + { + "number": 26, + "emoji": "🌽", + "description": "Corn", + "unicode": "U+1F33D", + "translated_descriptions": { + "ar": "ذُرَة", + "bg": "Царевица", + "ca": "Blat de moro", + "cs": "Kukuřice", + "de": "Mais", + "eo": "Maizo", + "es": "Maíz", + "et": "Mais", + "fi": "Maissi", + "fr": "Maïs", + "hr": "kukuruza", + "hu": "Kukorica", + "it": "Mais", + "ja": "とうもろこし", + "nb_NO": "Mais", + "nl": "Maïs", + "pt_BR": "Milho", + "ru": "Кукуруза", + "si": null, + "sk": "Kukuričný klas", + "sr": "кукуруз", + "sv": "Majs", + "szl": null, + "tzm": null, + "uk": "Кукурудза", + "zh_Hans": "玉米" + } + }, + { + "number": 27, + "emoji": "🍕", + "description": "Pizza", + "unicode": "U+1F355", + "translated_descriptions": { + "ar": "بِيتزا", + "bg": "Пица", + "ca": "Pizza", + "cs": "Pizza", + "de": "Pizza", + "eo": "Pico", + "es": "Pizza", + "et": "Pitsa", + "fi": "Pizza", + "fr": "Pizza", + "hr": "pizza", + "hu": "Pizza", + "it": "Pizza", + "ja": "ピザ", + "nb_NO": "Pizza", + "nl": "Pizza", + "pt_BR": "Pizza", + "ru": "Пицца", + "si": null, + "sk": "Pizza", + "sr": "пица", + "sv": "Pizza", + "szl": null, + "tzm": null, + "uk": "Піца", + "zh_Hans": "披萨" + } + }, + { + "number": 28, + "emoji": "🎂", + "description": "Cake", + "unicode": "U+1F382", + "translated_descriptions": { + "ar": "كَعكَة", + "bg": "Торта", + "ca": "Pastís", + "cs": "Dort", + "de": "Kuchen", + "eo": "Torto", + "es": "Tarta", + "et": "Kook", + "fi": "Kakku", + "fr": "Gâteau", + "hr": "torta", + "hu": "Süti", + "it": "Torta", + "ja": "ケーキ", + "nb_NO": "Kake", + "nl": "Taart", + "pt_BR": "Bolo", + "ru": "Торт", + "si": null, + "sk": "Narodeninová torta", + "sr": "торта", + "sv": "Tårta", + "szl": null, + "tzm": null, + "uk": "Пиріг", + "zh_Hans": "蛋糕" + } + }, + { + "number": 29, + "emoji": "❤️", + "description": "Heart", + "unicode": "U+2764U+FE0F", + "translated_descriptions": { + "ar": "قَلب", + "bg": "Сърце", + "ca": "Cor", + "cs": "Srdce", + "de": "Herz", + "eo": "Koro", + "es": "Corazón", + "et": "Süda", + "fi": "Sydän", + "fr": "Cœur", + "hr": "srca", + "hu": "Szív", + "it": "Cuore", + "ja": "ハート", + "nb_NO": "Hjerte", + "nl": "Hart", + "pt_BR": "Coração", + "ru": "Сердце", + "si": null, + "sk": "červené srdce", + "sr": "срце", + "sv": "Hjärta", + "szl": null, + "tzm": "Ul", + "uk": "Серце", + "zh_Hans": "心" + } + }, + { + "number": 30, + "emoji": "😀", + "description": "Smiley", + "unicode": "U+1F600", + "translated_descriptions": { + "ar": "اِبتِسَامَة", + "bg": "Усмивка", + "ca": "Somrient", + "cs": "Smajlík", + "de": "Lächeln", + "eo": "Rideto", + "es": "Emoticono", + "et": "Smaili", + "fi": "Hymynaama", + "fr": "Sourire", + "hr": "smajlića", + "hu": "Mosoly", + "it": "Faccina sorridente", + "ja": "スマイル", + "nb_NO": "Smilefjes", + "nl": "Smiley", + "pt_BR": "Sorriso", + "ru": "Улыбка", + "si": null, + "sk": "Škeriaca sa tvár", + "sr": "смајли", + "sv": "Smiley", + "szl": null, + "tzm": null, + "uk": "Посмішка", + "zh_Hans": "笑脸" + } + }, + { + "number": 31, + "emoji": "🤖", + "description": "Robot", + "unicode": "U+1F916", + "translated_descriptions": { + "ar": "رُوبُوت", + "bg": "Робот", + "ca": "Robot", + "cs": "Robot", + "de": "Roboter", + "eo": "Roboto", + "es": "Robot", + "et": "Robot", + "fi": "Robotti", + "fr": "Robot", + "hr": "robot", + "hu": "Robot", + "it": "Robot", + "ja": "ロボと", + "nb_NO": "Robot", + "nl": "Robot", + "pt_BR": "Robô", + "ru": "Робот", + "si": null, + "sk": "Robot", + "sr": "робот", + "sv": "Robot", + "szl": null, + "tzm": "Aṛubu", + "uk": "Робот", + "zh_Hans": "机器人" + } + }, + { + "number": 32, + "emoji": "🎩", + "description": "Hat", + "unicode": "U+1F3A9", + "translated_descriptions": { + "ar": "قُبَّعَة", + "bg": "Шапка", + "ca": "Barret", + "cs": "Klobouk", + "de": "Hut", + "eo": "Ĉapelo", + "es": "Sombrero", + "et": "Kübar", + "fi": "Hattu", + "fr": "Chapeau", + "hr": "kapa", + "hu": "Kalap", + "it": "Cappello", + "ja": "帽子", + "nb_NO": "Hatt", + "nl": "Hoed", + "pt_BR": "Chapéu", + "ru": "Шляпа", + "si": null, + "sk": "Cilinder", + "sr": "шешир", + "sv": "Hatt", + "szl": null, + "tzm": "Taraza", + "uk": "Капелюх", + "zh_Hans": "帽子" + } + }, + { + "number": 33, + "emoji": "👓", + "description": "Glasses", + "unicode": "U+1F453", + "translated_descriptions": { + "ar": "نَظَّارَة", + "bg": "Очила", + "ca": "Ulleres", + "cs": "Brýle", + "de": "Brille", + "eo": "Okulvitroj", + "es": "Gafas", + "et": "Prillid", + "fi": "Silmälasit", + "fr": "Lunettes", + "hr": "naočale", + "hu": "Szemüveg", + "it": "Occhiali", + "ja": "めがね", + "nb_NO": "Briller", + "nl": "Bril", + "pt_BR": "Óculos", + "ru": "Очки", + "si": null, + "sk": "Okuliare", + "sr": "наочаре", + "sv": "Glasögon", + "szl": null, + "tzm": null, + "uk": "Окуляри", + "zh_Hans": "眼镜" + } + }, + { + "number": 34, + "emoji": "🔧", + "description": "Spanner", + "unicode": "U+1F527", + "translated_descriptions": { + "ar": "مِفتَاحُ رَبط", + "bg": "Гаечен ключ", + "ca": "Clau anglesa", + "cs": "Klíč", + "de": "Schraubenschlüssel", + "eo": "Ŝraŭbŝlosilo", + "es": "Llave inglesa", + "et": "Mutrivõti", + "fi": "Kiintoavain", + "fr": "Clé à molette", + "hr": "ključ", + "hu": "Csavarkulcs", + "it": "Chiave inglese", + "ja": "スパナ", + "nb_NO": "Fastnøkkel", + "nl": "Moersleutel", + "pt_BR": "Chave inglesa", + "ru": "Ключ", + "si": null, + "sk": "Francúzsky kľúč", + "sr": "кључ", + "sv": "Skruvnyckel", + "szl": null, + "tzm": null, + "uk": "Гайковий ключ", + "zh_Hans": "扳手" + } + }, + { + "number": 35, + "emoji": "🎅", + "description": "Santa", + "unicode": "U+1F385", + "translated_descriptions": { + "ar": "سانتا", + "bg": "Дядо Коледа", + "ca": "Pare Noél", + "cs": "Mikuláš", + "de": "Weihnachtsmann", + "eo": "Kristnaska viro", + "es": "Papá Noel", + "et": "Jõuluvana", + "fi": "Joulupukki", + "fr": "Père Noël", + "hr": "deda Mraz", + "hu": "Télapó", + "it": "Babbo Natale", + "ja": "サンタ", + "nb_NO": "Julenisse", + "nl": "Kerstman", + "pt_BR": "Papai-noel", + "ru": "Санта", + "si": null, + "sk": "Santa Claus", + "sr": "деда Мраз", + "sv": "Tomte", + "szl": null, + "tzm": null, + "uk": "Санта Клаус", + "zh_Hans": "圣诞老人" + } + }, + { + "number": 36, + "emoji": "👍", + "description": "Thumbs Up", + "unicode": "U+1F44D", + "translated_descriptions": { + "ar": "رَفعُ إِبهَام", + "bg": "Палец нагоре", + "ca": "Polzes amunt", + "cs": "Palec nahoru", + "de": "Daumen Hoch", + "eo": "Dikfingro supren", + "es": "Pulgar arriba", + "et": "Pöidlad püsti", + "fi": "Peukalo ylös", + "fr": "Pouce en l’air", + "hr": "palac gore", + "hu": "Hüvelykujj fel", + "it": "Pollice alzato", + "ja": "いいね", + "nb_NO": "Tommel Opp", + "nl": "Duim omhoog", + "pt_BR": "Joinha", + "ru": "Большой палец вверх", + "si": null, + "sk": "Palec nahor", + "sr": "палчић горе", + "sv": "Tummen upp", + "szl": null, + "tzm": null, + "uk": "Великий палець вгору", + "zh_Hans": "赞" + } + }, + { + "number": 37, + "emoji": "☂️", + "description": "Umbrella", + "unicode": "U+2602U+FE0F", + "translated_descriptions": { + "ar": "مِظَلَّة", + "bg": "Чадър", + "ca": "Paraigües", + "cs": "Deštník", + "de": "Regenschirm", + "eo": "Ombrelo", + "es": "Paraguas", + "et": "Vihmavari", + "fi": "Sateenvarjo", + "fr": "Parapluie", + "hr": "kišobran", + "hu": "Esernyő", + "it": "Ombrello", + "ja": "傘", + "nb_NO": "Paraply", + "nl": "Paraplu", + "pt_BR": "Guarda-chuva", + "ru": "Зонт", + "si": null, + "sk": "Dáždnik", + "sr": "кишобран", + "sv": "Paraply", + "szl": null, + "tzm": null, + "uk": "Парасолька", + "zh_Hans": "伞" + } + }, + { + "number": 38, + "emoji": "⌛", + "description": "Hourglass", + "unicode": "U+231B", + "translated_descriptions": { + "ar": "سَاعَةٌ رَملِيَّة", + "bg": "Пясъчен часовник", + "ca": "Rellotge de sorra", + "cs": "Přesýpací hodiny", + "de": "Sanduhr", + "eo": "Sablohorloĝo", + "es": "Reloj de arena", + "et": "Liivakell", + "fi": "Tiimalasi", + "fr": "Sablier", + "hr": "pješčani sat", + "hu": "Homokóra", + "it": "Clessidra", + "ja": "砂時計", + "nb_NO": "Timeglass", + "nl": "Zandloper", + "pt_BR": "Ampulheta", + "ru": "Песочные часы", + "si": null, + "sk": "Presýpacie hodiny", + "sr": "пешчаник", + "sv": "Timglas", + "szl": null, + "tzm": null, + "uk": "Пісковий годинник", + "zh_Hans": "沙漏" + } + }, + { + "number": 39, + "emoji": "⏰", + "description": "Clock", + "unicode": "U+23F0", + "translated_descriptions": { + "ar": "سَاعَة", + "bg": "Часовник", + "ca": "Rellotge", + "cs": "Hodiny", + "de": "Uhr", + "eo": "Horloĝo", + "es": "Reloj", + "et": "Kell", + "fi": "Pöytäkello", + "fr": "Réveil", + "hr": "sat", + "hu": "Óra", + "it": "Orologio", + "ja": "時計", + "nb_NO": "Klokke", + "nl": "Wekker", + "pt_BR": "Relógio", + "ru": "Часы", + "si": null, + "sk": "Budík", + "sr": "сат", + "sv": "Klocka", + "szl": null, + "tzm": null, + "uk": "Годинник", + "zh_Hans": "时钟" + } + }, + { + "number": 40, + "emoji": "🎁", + "description": "Gift", + "unicode": "U+1F381", + "translated_descriptions": { + "ar": "هَدِيَّة", + "bg": "Подарък", + "ca": "Regal", + "cs": "Dárek", + "de": "Geschenk", + "eo": "Donaco", + "es": "Regalo", + "et": "Kingitus", + "fi": "Lahja", + "fr": "Cadeau", + "hr": "poklon", + "hu": "Ajándék", + "it": "Regalo", + "ja": "ギフト", + "nb_NO": "Gave", + "nl": "Geschenk", + "pt_BR": "Presente", + "ru": "Подарок", + "si": null, + "sk": "Zabalený darček", + "sr": "поклон", + "sv": "Present", + "szl": null, + "tzm": null, + "uk": "Подарунок", + "zh_Hans": "礼物" + } + }, + { + "number": 41, + "emoji": "💡", + "description": "Light Bulb", + "unicode": "U+1F4A1", + "translated_descriptions": { + "ar": "مِصبَاح", + "bg": "Лампа", + "ca": "Bombeta", + "cs": "Žárovka", + "de": "Glühbirne", + "eo": "Lampo", + "es": "Bombilla", + "et": "Lambipirn", + "fi": "Hehkulamppu", + "fr": "Ampoule", + "hr": "žarulja", + "hu": "Égő", + "it": "Lampadina", + "ja": "電球", + "nb_NO": "Lyspære", + "nl": "Gloeilamp", + "pt_BR": "Lâmpada", + "ru": "Лампочка", + "si": null, + "sk": "Žiarovka", + "sr": "сијалица", + "sv": "Lampa", + "szl": null, + "tzm": null, + "uk": "Лампочка", + "zh_Hans": "灯泡" + } + }, + { + "number": 42, + "emoji": "📕", + "description": "Book", + "unicode": "U+1F4D5", + "translated_descriptions": { + "ar": "كِتَاب", + "bg": "Книга", + "ca": "Llibre", + "cs": "Kniha", + "de": "Buch", + "eo": "Libro", + "es": "Libro", + "et": "Raamat", + "fi": "Kirja", + "fr": "Livre", + "hr": "knjiga", + "hu": "Könyv", + "it": "Libro", + "ja": "本", + "nb_NO": "Bok", + "nl": "Boek", + "pt_BR": "Livro", + "ru": "Книга", + "si": null, + "sk": "Zatvorená kniha", + "sr": "књига", + "sv": "Bok", + "szl": null, + "tzm": "Adlis", + "uk": "Книга", + "zh_Hans": "书" + } + }, + { + "number": 43, + "emoji": "✏️", + "description": "Pencil", + "unicode": "U+270FU+FE0F", + "translated_descriptions": { + "ar": "قَلَمُ رَصاص", + "bg": "Молив", + "ca": "Llapis", + "cs": "Tužka", + "de": "Bleistift", + "eo": "Krajono", + "es": "Lápiz", + "et": "Pliiats", + "fi": "Lyijykynä", + "fr": "Crayon", + "hr": "olovka", + "hu": "Ceruza", + "it": "Matita", + "ja": "鉛筆", + "nb_NO": "Blyant", + "nl": "Potlood", + "pt_BR": "Lápis", + "ru": "Карандаш", + "si": null, + "sk": "Ceruzka", + "sr": "оловка", + "sv": "Penna", + "szl": null, + "tzm": null, + "uk": "Олівець", + "zh_Hans": "铅笔" + } + }, + { + "number": 44, + "emoji": "📎", + "description": "Paperclip", + "unicode": "U+1F4CE", + "translated_descriptions": { + "ar": "مِشبَكُ وَرَق", + "bg": "Кламер", + "ca": "Clip", + "cs": "Sponka", + "de": "Büroklammer", + "eo": "Paperkuntenilo", + "es": "Clip", + "et": "Kirjaklamber", + "fi": "Paperiliitin", + "fr": "Trombone", + "hr": "spajalica", + "hu": "Gémkapocs", + "it": "Graffetta", + "ja": "クリップ", + "nb_NO": "BInders", + "nl": "Papierklemmetje", + "pt_BR": "Clipe de papel", + "ru": "Скрепка", + "si": null, + "sk": "Sponka na papier", + "sr": "спајалица", + "sv": "Gem", + "szl": null, + "tzm": null, + "uk": "Спиначка", + "zh_Hans": "回形针" + } + }, + { + "number": 45, + "emoji": "✂️", + "description": "Scissors", + "unicode": "U+2702U+FE0F", + "translated_descriptions": { + "ar": "مِقَصّ", + "bg": "Ножици", + "ca": "Tisores", + "cs": "Nůžky", + "de": "Schere", + "eo": "Tondilo", + "es": "Tijeras", + "et": "Käärid", + "fi": "Sakset", + "fr": "Ciseaux", + "hr": "škare", + "hu": "Olló", + "it": "Forbici", + "ja": "はさみ", + "nb_NO": "Saks", + "nl": "Schaar", + "pt_BR": "Tesoura", + "ru": "Ножницы", + "si": null, + "sk": "Nožnice", + "sr": "маказе", + "sv": "Sax", + "szl": null, + "tzm": null, + "uk": "Ножиці", + "zh_Hans": "剪刀" + } + }, + { + "number": 46, + "emoji": "🔒", + "description": "Lock", + "unicode": "U+1F512", + "translated_descriptions": { + "ar": "قُفل", + "bg": "Катинар", + "ca": "Cadenat", + "cs": "Zámek", + "de": "Schloss", + "eo": "Seruro", + "es": "Candado", + "et": "Lukk", + "fi": "Lukko", + "fr": "Cadenas", + "hr": "zaključati", + "hu": "Lakat", + "it": "Lucchetto", + "ja": "錠前", + "nb_NO": "Lås", + "nl": "Slot", + "pt_BR": "Cadeado", + "ru": "Замок", + "si": null, + "sk": "Zatvorená zámka", + "sr": "катанац", + "sv": "Lås", + "szl": null, + "tzm": null, + "uk": "Замок", + "zh_Hans": "锁" + } + }, + { + "number": 47, + "emoji": "🔑", + "description": "Key", + "unicode": "U+1F511", + "translated_descriptions": { + "ar": "مِفتَاح", + "bg": "Ключ", + "ca": "Clau", + "cs": "Klíč", + "de": "Schlüssel", + "eo": "Ŝlosilo", + "es": "Llave", + "et": "Võti", + "fi": "Avain", + "fr": "Clé", + "hr": "ključ", + "hu": "Kulcs", + "it": "Chiave", + "ja": "鍵", + "nb_NO": "Nøkkel", + "nl": "Sleutel", + "pt_BR": "Chave", + "ru": "Ключ", + "si": null, + "sk": "Kľúč", + "sr": "кључ", + "sv": "Nyckel", + "szl": null, + "tzm": "Tasarut", + "uk": "Ключ", + "zh_Hans": "钥匙" + } + }, + { + "number": 48, + "emoji": "🔨", + "description": "Hammer", + "unicode": "U+1F528", + "translated_descriptions": { + "ar": "مِطرَقَة", + "bg": "Чук", + "ca": "Martell", + "cs": "Kladivo", + "de": "Hammer", + "eo": "Martelo", + "es": "Martillo", + "et": "Haamer", + "fi": "Vasara", + "fr": "Marteau", + "hr": "čekić", + "hu": "Kalapács", + "it": "Martello", + "ja": "金槌", + "nb_NO": "Hammer", + "nl": "Hamer", + "pt_BR": "Martelo", + "ru": "Молоток", + "si": null, + "sk": "Kladivo", + "sr": "чекић", + "sv": "Hammare", + "szl": null, + "tzm": null, + "uk": "Молоток", + "zh_Hans": "锤子" + } + }, + { + "number": 49, + "emoji": "☎️", + "description": "Telephone", + "unicode": "U+260EU+FE0F", + "translated_descriptions": { + "ar": "تِلِفُون", + "bg": "Телефон", + "ca": "Telèfon", + "cs": "Telefon", + "de": "Telefon", + "eo": "Telefono", + "es": "Telefono", + "et": "Telefon", + "fi": "Puhelin", + "fr": "Téléphone", + "hr": "telefon", + "hu": "Telefon", + "it": "Telefono", + "ja": "電話機", + "nb_NO": "Telefon", + "nl": "Telefoon", + "pt_BR": "Telefone", + "ru": "Телефон", + "si": null, + "sk": "Telefón", + "sr": "телефон", + "sv": "Telefon", + "szl": null, + "tzm": "Atilifun", + "uk": "Телефон", + "zh_Hans": "电话" + } + }, + { + "number": 50, + "emoji": "🏁", + "description": "Flag", + "unicode": "U+1F3C1", + "translated_descriptions": { + "ar": "عَلَم", + "bg": "Флаг", + "ca": "Bandera", + "cs": "Vlajka", + "de": "Flagge", + "eo": "Flago", + "es": "Bandera", + "et": "Lipp", + "fi": "Lippu", + "fr": "Drapeau", + "hr": "zastava", + "hu": "Zászló", + "it": "Bandiera", + "ja": "旗", + "nb_NO": "Flagg", + "nl": "Vlag", + "pt_BR": "Bandeira", + "ru": "Флаг", + "si": null, + "sk": "Kockovaná zástava", + "sr": "застава", + "sv": "Flagga", + "szl": null, + "tzm": "Acenyal", + "uk": "Прапор", + "zh_Hans": "旗帜" + } + }, + { + "number": 51, + "emoji": "🚂", + "description": "Train", + "unicode": "U+1F682", + "translated_descriptions": { + "ar": "قِطَار", + "bg": "Влак", + "ca": "Tren", + "cs": "Vlak", + "de": "Zug", + "eo": "Vagonaro", + "es": "Tren", + "et": "Rong", + "fi": "Juna", + "fr": "Train", + "hr": "vlak", + "hu": "Vonat", + "it": "Treno", + "ja": "電車", + "nb_NO": "Tog", + "nl": "Trein", + "pt_BR": "Trem", + "ru": "Поезд", + "si": null, + "sk": "Rušeň", + "sr": "воз", + "sv": "Tåg", + "szl": null, + "tzm": null, + "uk": "Потяг", + "zh_Hans": "火车" + } + }, + { + "number": 52, + "emoji": "🚲", + "description": "Bicycle", + "unicode": "U+1F6B2", + "translated_descriptions": { + "ar": "دَرّاجَة", + "bg": "Колело", + "ca": "Bicicleta", + "cs": "Kolo", + "de": "Fahrrad", + "eo": "Biciklo", + "es": "Bicicleta", + "et": "Jalgratas", + "fi": "Polkupyörä", + "fr": "Vélo", + "hr": "bicikl", + "hu": "Kerékpár", + "it": "Bicicletta", + "ja": "自転車", + "nb_NO": "Sykkel", + "nl": "Fiets", + "pt_BR": "Bicicleta", + "ru": "Велосипед", + "si": null, + "sk": "Bicykel", + "sr": "бицикл", + "sv": "Cykel", + "szl": null, + "tzm": null, + "uk": "Велосипед", + "zh_Hans": "自行车" + } + }, + { + "number": 53, + "emoji": "✈️", + "description": "Aeroplane", + "unicode": "U+2708U+FE0F", + "translated_descriptions": { + "ar": "طَائِرة", + "bg": "Самолет", + "ca": "Avió", + "cs": "Letadlo", + "de": "Flugzeug", + "eo": "Aviadilo", + "es": "Avión", + "et": "Lennuk", + "fi": "Lentokone", + "fr": "Avion", + "hr": "avion", + "hu": "Repülő", + "it": "Aeroplano", + "ja": "飛行機", + "nb_NO": "Fly", + "nl": "Vliegtuig", + "pt_BR": "Avião", + "ru": "Самолет", + "si": null, + "sk": "Lietadlo", + "sr": "авион", + "sv": "Flygplan", + "szl": null, + "tzm": null, + "uk": "Літак", + "zh_Hans": "飞机" + } + }, + { + "number": 54, + "emoji": "🚀", + "description": "Rocket", + "unicode": "U+1F680", + "translated_descriptions": { + "ar": "صَارُوخ", + "bg": "Ракета", + "ca": "Coet", + "cs": "Raketa", + "de": "Rakete", + "eo": "Raketo", + "es": "Cohete", + "et": "Rakett", + "fi": "Raketti", + "fr": "Fusée", + "hr": "raketa", + "hu": "Rakáta", + "it": "Razzo", + "ja": "ロケット", + "nb_NO": "Rakett", + "nl": "Raket", + "pt_BR": "Foguete", + "ru": "Ракета", + "si": null, + "sk": "Raketa", + "sr": "ракета", + "sv": "Raket", + "szl": null, + "tzm": null, + "uk": "Ракета", + "zh_Hans": "火箭" + } + }, + { + "number": 55, + "emoji": "🏆", + "description": "Trophy", + "unicode": "U+1F3C6", + "translated_descriptions": { + "ar": "كَأسُ النَّصر", + "bg": "Трофей", + "ca": "Trofeu", + "cs": "Pohár", + "de": "Pokal", + "eo": "Trofeo", + "es": "Trofeo", + "et": "Auhind", + "fi": "Palkinto", + "fr": "Trophée", + "hr": "trofej", + "hu": "Trófea", + "it": "Trofeo", + "ja": "トロフィー", + "nb_NO": "Pokal", + "nl": "Trofee", + "pt_BR": "Troféu", + "ru": "Кубок", + "si": null, + "sk": "Trofej", + "sr": "пехар", + "sv": "Trofé", + "szl": null, + "tzm": null, + "uk": "Приз", + "zh_Hans": "奖杯" + } + }, + { + "number": 56, + "emoji": "⚽", + "description": "Ball", + "unicode": "U+26BD", + "translated_descriptions": { + "ar": "كُرَة", + "bg": "Топка", + "ca": "Pilota", + "cs": "Míč", + "de": "Ball", + "eo": "Pilko", + "es": "Bola", + "et": "Pall", + "fi": "Pallo", + "fr": "Ballon", + "hr": "lopta", + "hu": "Labda", + "it": "Palla", + "ja": "ボール", + "nb_NO": "Ball", + "nl": "Bal", + "pt_BR": "Bola", + "ru": "Мяч", + "si": null, + "sk": "Futbal", + "sr": "лопта", + "sv": "Boll", + "szl": null, + "tzm": "Tcama", + "uk": "М'яч", + "zh_Hans": "球" + } + }, + { + "number": 57, + "emoji": "🎸", + "description": "Guitar", + "unicode": "U+1F3B8", + "translated_descriptions": { + "ar": "غيتار", + "bg": "Китара", + "ca": "Guitarra", + "cs": "Kytara", + "de": "Gitarre", + "eo": "Gitaro", + "es": "Guitarra", + "et": "Kitarr", + "fi": "Kitara", + "fr": "Guitare", + "hr": "gitara", + "hu": "Gitár", + "it": "Chitarra", + "ja": "ギター", + "nb_NO": "Gitar", + "nl": "Gitaar", + "pt_BR": "Guitarra", + "ru": "Гитара", + "si": null, + "sk": "Gitara", + "sr": "гитара", + "sv": "Gitarr", + "szl": null, + "tzm": "Agiṭaṛ", + "uk": "Гітара", + "zh_Hans": "吉他" + } + }, + { + "number": 58, + "emoji": "🎺", + "description": "Trumpet", + "unicode": "U+1F3BA", + "translated_descriptions": { + "ar": "بُوق", + "bg": "Тромпет", + "ca": "Trompeta", + "cs": "Trumpeta", + "de": "Trompete", + "eo": "Trumpeto", + "es": "Trompeta", + "et": "Trompet", + "fi": "Trumpetti", + "fr": "Trompette", + "hr": "truba", + "hu": "Trombita", + "it": "Trombetta", + "ja": "トランペット", + "nb_NO": "Trompet", + "nl": "Trompet", + "pt_BR": "Trombeta", + "ru": "Труба", + "si": null, + "sk": "Trúbka", + "sr": "труба", + "sv": "Trumpet", + "szl": null, + "tzm": null, + "uk": "Труба", + "zh_Hans": "喇叭" + } + }, + { + "number": 59, + "emoji": "🔔", + "description": "Bell", + "unicode": "U+1F514", + "translated_descriptions": { + "ar": "جَرَس", + "bg": "Звънец", + "ca": "Campana", + "cs": "Zvonek", + "de": "Glocke", + "eo": "Sonorilo", + "es": "Campana", + "et": "Kelluke", + "fi": "Soittokello", + "fr": "Cloche", + "hr": "zvono", + "hu": "Harang", + "it": "Campana", + "ja": "ベル", + "nb_NO": "Bjelle", + "nl": "Bel", + "pt_BR": "Sino", + "ru": "Колокол", + "si": null, + "sk": "Zvon", + "sr": "звоно", + "sv": "Bjällra", + "szl": null, + "tzm": null, + "uk": "Дзвін", + "zh_Hans": "铃铛" + } + }, + { + "number": 60, + "emoji": "⚓", + "description": "Anchor", + "unicode": "U+2693", + "translated_descriptions": { + "ar": "مِرسَاة", + "bg": "Котва", + "ca": "Àncora", + "cs": "Kotva", + "de": "Anker", + "eo": "Ankro", + "es": "Ancla", + "et": "Ankur", + "fi": "Ankkuri", + "fr": "Ancre", + "hr": "sidro", + "hu": "Horgony", + "it": "Ancora", + "ja": "いかり", + "nb_NO": "Anker", + "nl": "Anker", + "pt_BR": "Âncora", + "ru": "Якорь", + "si": null, + "sk": "Kotva", + "sr": "сидро", + "sv": "Ankare", + "szl": null, + "tzm": null, + "uk": "Якір", + "zh_Hans": "锚" + } + }, + { + "number": 61, + "emoji": "🎧", + "description": "Headphones", + "unicode": "U+1F3A7", + "translated_descriptions": { + "ar": "سَمّاعَة رَأس", + "bg": "Слушалки", + "ca": "Auriculars", + "cs": "Sluchátka", + "de": "Kopfhörer", + "eo": "Kapaŭdilo", + "es": "Cascos", + "et": "Kõrvaklapid", + "fi": "Kuulokkeet", + "fr": "Casque audio", + "hr": "slušalice", + "hu": "Fejhallgató", + "it": "Cuffie", + "ja": "ヘッドホン", + "nb_NO": "Hodetelefoner", + "nl": "Koptelefoon", + "pt_BR": "Fones de ouvido", + "ru": "Наушники", + "si": null, + "sk": "Slúchadlá", + "sr": "слушалице", + "sv": "Hörlurar", + "szl": null, + "tzm": null, + "uk": "Навушники", + "zh_Hans": "耳机" + } + }, + { + "number": 62, + "emoji": "📁", + "description": "Folder", + "unicode": "U+1F4C1", + "translated_descriptions": { + "ar": "مُجَلَّد", + "bg": "Папка", + "ca": "Carpeta", + "cs": "Složka", + "de": "Ordner", + "eo": "Dosierujo", + "es": "Carpeta", + "et": "Kaust", + "fi": "Kansio", + "fr": "Dossier", + "hr": "mapu", + "hu": "Mappa", + "it": "Cartella", + "ja": "フォルダ", + "nb_NO": "Mappe", + "nl": "Map", + "pt_BR": "Pasta", + "ru": "Папка", + "si": null, + "sk": "Fascikel", + "sr": "фасцикла", + "sv": "Mapp", + "szl": null, + "tzm": "Asdaw", + "uk": "Тека", + "zh_Hans": "文件夹" + } + }, + { + "number": 63, + "emoji": "📌", + "description": "Pin", + "unicode": "U+1F4CC", + "translated_descriptions": { + "ar": "دَبُّوس", + "bg": "Кабърче", + "ca": "Xinxeta", + "cs": "Špendlík", + "de": "Stecknadel", + "eo": "Pinglo", + "es": "Alfiler", + "et": "Nööpnõel", + "fi": "Nuppineula", + "fr": "Punaise", + "hr": "pribadača", + "hu": "Rajszeg", + "it": "Puntina", + "ja": "ピン", + "nb_NO": "Tegnestift", + "nl": "Duimspijker", + "pt_BR": "Alfinete", + "ru": "Булавка", + "si": null, + "sk": "Špendlík", + "sr": "чиода", + "sv": "Häftstift", + "szl": null, + "tzm": null, + "uk": "Кнопка", + "zh_Hans": "图钉" + } + } +] \ No newline at end of file diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..c3dc2f3 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +nullable-getter: false +untranslated-messages-file: needs_translation.json \ No newline at end of file diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart new file mode 100644 index 0000000..1170818 --- /dev/null +++ b/lib/app/view/app.dart @@ -0,0 +1,65 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../chat/view/chat_start_page.dart'; +import '../../constants.dart'; +import '../../l10n/l10n.dart'; + +class NebuchadnezzarApp extends StatelessWidget { + const NebuchadnezzarApp({super.key}); + + @override + Widget build(BuildContext context) => Platform.isLinux + ? YaruTheme( + builder: (context, yaru, child) => App( + lightTheme: yaru.theme, + darkTheme: yaru.darkTheme, + highContrastTheme: yaruHighContrastLight, + highContrastDarkTheme: yaruHighContrastDark, + ), + ) + : App( + lightTheme: yaruLight, + darkTheme: yaruDark, + ); +} + +class App extends StatelessWidget { + const App({ + super.key, + this.lightTheme, + this.darkTheme, + this.highContrastTheme, + this.highContrastDarkTheme, + }); + + final ThemeData? lightTheme, + darkTheme, + highContrastTheme, + highContrastDarkTheme; + + @override + Widget build(BuildContext context) => MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + highContrastTheme: highContrastTheme, + highContrastDarkTheme: highContrastDarkTheme, + debugShowCheckedModeBanner: false, + title: kAppTitle, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: supportedLocales, + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.unknown, + PointerDeviceKind.trackpad, + }, + ), + home: const ChatStartPage(), + ); +} diff --git a/lib/app_config.dart b/lib/app_config.dart new file mode 100644 index 0000000..ef2c0d2 --- /dev/null +++ b/lib/app_config.dart @@ -0,0 +1,8 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +bool yaru = !kIsWeb && (Platform.isLinux || Platform.isMacOS); + +bool isMobilePlatform = + !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isFuchsia); diff --git a/lib/chat/authentication/authentication_model.dart b/lib/chat/authentication/authentication_model.dart new file mode 100644 index 0000000..388eef7 --- /dev/null +++ b/lib/chat/authentication/authentication_model.dart @@ -0,0 +1,71 @@ +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +class AuthenticationModel extends SafeChangeNotifier { + AuthenticationModel({required Client client}) : _client = client; + + final Client _client; + + bool _processingAccess = false; + bool get processingAccess => _processingAccess; + void _setProcessingAccess(bool value) { + if (value == _processingAccess) return; + _processingAccess = value; + notifyListeners(); + } + + bool _showPassword = false; + bool get showPassword => _showPassword; + void toggleShowPassword({bool? forceValue}) { + _showPassword = forceValue ?? !_showPassword; + notifyListeners(); + } + + Future login({ + required String homeServer, + required String username, + required String password, + required Function(String error) onFail, + required Future Function() onSuccess, + }) async { + _setProcessingAccess(true); + try { + await _client.checkHomeserver(Uri.https(homeServer, '')); + + await _client.login( + LoginType.mLoginPassword, + password: password, + identifier: AuthenticationUserIdentifier(user: username), + ); + await _client.firstSyncReceived; + await _client.roomsLoading; + + await _loadMediaConfig(); + await onSuccess(); + } on Exception catch (e) { + await onFail(e.toString()); + } finally { + _setProcessingAccess(false); + } + } + + Future logout({ + required Function(String error) onFail, + }) async { + _setProcessingAccess(true); + try { + await _client.logout(); + } on Exception catch (e) { + onFail(e.toString()); + } finally { + _setProcessingAccess(false); + } + } + + int get maxUploadSize => _mediaConfig?.mUploadSize ?? 100 * 1000 * 1000; + MediaConfig? _mediaConfig; + + Future _loadMediaConfig() async { + _mediaConfig = await _client.getConfig(); + } +} diff --git a/lib/chat/authentication/chat_login_page.dart b/lib/chat/authentication/chat_login_page.dart new file mode 100644 index 0000000..be200f2 --- /dev/null +++ b/lib/chat/authentication/chat_login_page.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:liquid_progress_indicator_v2/liquid_progress_indicator.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/build_context_x.dart'; +import '../../common/view/snackbars.dart'; +import '../../common/view/space.dart'; +import '../../common/view/ui_constants.dart'; +import '../../constants.dart'; +import '../../l10n/l10n.dart'; +import '../view/chat_master/chat_master_detail_page.dart'; +import 'authentication_model.dart'; + +class ChatLoginPage extends StatefulWidget with WatchItStatefulWidgetMixin { + const ChatLoginPage({super.key}); + + @override + State createState() => _ChatLoginPageState(); +} + +class _ChatLoginPageState extends State { + final TextEditingController _homeServerController = + TextEditingController(text: 'matrix.org'); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + + @override + void dispose() { + _homeServerController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final authenticationModel = di(); + bool processingAccess = + watchPropertyValue((AuthenticationModel m) => m.processingAccess); + bool showPassword = + watchPropertyValue((AuthenticationModel m) => m.showPassword); + + var onPressed = processingAccess + ? null + : () async { + authenticationModel.toggleShowPassword(forceValue: false); + return authenticationModel.login( + homeServer: _homeServerController.text.trim(), + username: _usernameController.text, + password: _passwordController.text, + onSuccess: () async => Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => const ChatMasterDetailPage(), + ), + (route) => false, + ), + onFail: (e) => showSnackBar(context, content: Text(e.toString())), + ); + }; + + return Scaffold( + appBar: const YaruWindowTitleBar( + title: Text(kAppTitle), + backgroundColor: Colors.transparent, + border: BorderSide.none, + ), + body: Stack( + children: [ + Center( + child: SizedBox( + width: kLoginFormWidth, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: kBigPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: space( + heightGap: kMediumPadding, + children: [ + TextField( + controller: _homeServerController, + readOnly: processingAccess, + autocorrect: false, + onSubmitted: (value) => onPressed?.call(), + decoration: InputDecoration( + prefixText: 'https://', + labelText: l10n.homeserver, + ), + ), + TextField( + controller: _usernameController, + readOnly: processingAccess, + autocorrect: false, + onSubmitted: (value) => onPressed?.call(), + decoration: InputDecoration( + labelText: l10n.username, + ), + ), + TextField( + controller: _passwordController, + readOnly: processingAccess, + autocorrect: false, + obscureText: !showPassword, + onSubmitted: (value) => onPressed?.call(), + decoration: InputDecoration( + labelText: l10n.password, + suffixIconConstraints: const BoxConstraints( + maxHeight: kYaruTitleBarItemHeight, + ), + suffixIcon: IconButton( + isSelected: showPassword, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + ), + ), + onPressed: authenticationModel.toggleShowPassword, + icon: Icon( + showPassword + ? YaruIcons.eye_filled + : YaruIcons.eye, + ), + ), + ), + ), + SizedBox( + width: double.infinity, + height: 35, + child: ElevatedButton( + onPressed: onPressed, + child: const Text('Login'), + ), + ), + const SizedBox( + height: kYaruTitleBarHeight, + ), + ], + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + child: AnimatedContainer( + duration: const Duration(seconds: 5), + height: processingAccess ? 350 : 120, + width: context.mediaQuerySize.width, + child: LiquidLinearProgressIndicator( + borderColor: Colors.transparent, + backgroundColor: Colors.transparent, + borderWidth: 0, + direction: Axis.vertical, + valueColor: AlwaysStoppedAnimation( + context.colorScheme.primary.withValues(alpha: 0.8), + ), + ), + ), + ), + Positioned( + bottom: 0, + child: SizedBox( + height: 280, + width: context.mediaQuerySize.width, + child: Padding( + padding: const EdgeInsets.only(top: 80), + child: Icon( + YaruIcons.ubuntu_logo_simple, + size: 60, + color: context.theme.scaffoldBackgroundColor, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/chat/bootstrap/bootrap_state_x.dart b/lib/chat/bootstrap/bootrap_state_x.dart new file mode 100644 index 0000000..73af0db --- /dev/null +++ b/lib/chat/bootstrap/bootrap_state_x.dart @@ -0,0 +1,9 @@ +import 'package:matrix/encryption/utils/bootstrap.dart'; + +import '../../l10n/l10n.dart'; + +extension BootstrapStateX on BootstrapState { + String? localize(AppLocalizations l10n) => switch (this) { + _ => l10n.loadingPleaseWait, + }; +} diff --git a/lib/chat/bootstrap/bootstrap_model.dart b/lib/chat/bootstrap/bootstrap_model.dart new file mode 100644 index 0000000..7958273 --- /dev/null +++ b/lib/chat/bootstrap/bootstrap_model.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import '../../l10n/l10n.dart'; + +class BootstrapModel extends SafeChangeNotifier { + BootstrapModel({ + required Client client, + required FlutterSecureStorage secureStorage, + }) : _client = client, + _secureStorage = secureStorage; + + final Client _client; + final FlutterSecureStorage _secureStorage; + + Future isBootrapNeeded() async => + _client.isUnknownSession && _client.encryption!.crossSigning.enabled; + + String get secureStorageKey => 'ssss_recovery_key_${_client.userID}'; + + bool _storeInSecureStorage = false; + bool get storeInSecureStorage => _storeInSecureStorage; + void setStoreInSecureStorage(bool value) { + if (_storeInSecureStorage == value) return; + _storeInSecureStorage = value; + notifyListeners(); + } + + String? _recoveryKeyInputError; + String? get recoveryKeyInputError => _recoveryKeyInputError; + void setRecoveryKeyInputError(String? value) { + if (_recoveryKeyInputError == value) return; + _recoveryKeyInputError = value; + notifyListeners(); + } + + bool _recoveryKeyInputLoading = false; + bool get recoveryKeyInputLoading => _recoveryKeyInputLoading; + void setRecoveryKeyInputLoading(bool value) { + if (_recoveryKeyInputLoading == value) return; + _recoveryKeyInputLoading = value; + notifyListeners(); + } + + bool _recoveryKeyStored = false; + bool get recoveryKeyStored => _recoveryKeyStored; + void setRecoveryKeyStored(bool value) { + if (_recoveryKeyStored == value) return; + _recoveryKeyStored = value; + notifyListeners(); + } + + bool _recoveryKeyCopied = false; + bool get recoveryKeyCopied => _recoveryKeyCopied; + void setRecoveryKeyCopied(bool value) { + if (_recoveryKeyCopied == value) return; + _recoveryKeyCopied = value; + notifyListeners(); + } + + void storeRecoveryKey() { + if (storeInSecureStorage) { + const FlutterSecureStorage().write( + key: secureStorageKey, + value: key, + ); + } + setRecoveryKeyStored(true); + } + + Future _loadKeyFromSecureStorage() async => + _secureStorage.read(key: secureStorageKey); + + String? _key; + String? get key => _key; + Bootstrap? _bootstrap; + Bootstrap? get bootstrap => _bootstrap; + void _setBootsTrap(Bootstrap bootstrap) { + _bootstrap = bootstrap; + _key = bootstrap.newSsssKey?.recoveryKey; + notifyListeners(); + } + + bool _wipe = false; + bool get wipe => _wipe; + Future startBootstrap({required bool wipe}) async { + _wipe = wipe; + _recoveryKeyStored = false; + _bootstrap = + _client.encryption?.bootstrap(onUpdate: (v) => _setBootsTrap(v)); + final theKey = await _loadKeyFromSecureStorage(); + if (key == null) { + notifyListeners(); + return; + } + + _key = theKey; + notifyListeners(); + } + + Future startKeyVerification() async { + if (_client.userID != null && + _client.userDeviceKeys[_client.userID!] != null) { + await _client.updateUserDeviceKeys(); + return _client.userDeviceKeys[_client.userID!]!.startVerification(); + } else { + return Future.error('Unknown userID'); + } + } + + bool get supportsSecureStorage => !kIsWeb; + + String getSecureStorageLocalizedName(AppLocalizations l10n) { + if (Platform.isAndroid) { + return l10n.storeInAndroidKeystore; + } + if (Platform.isIOS || Platform.isMacOS) { + return l10n.storeInAppleKeyChain; + } + return l10n.storeSecurlyOnThisDevice; + } +} diff --git a/lib/chat/bootstrap/view/bootstrap_page.dart b/lib/chat/bootstrap/view/bootstrap_page.dart new file mode 100644 index 0000000..70e7eb4 --- /dev/null +++ b/lib/chat/bootstrap/view/bootstrap_page.dart @@ -0,0 +1,455 @@ +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/space.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../bootstrap_model.dart'; +import 'key_verification_dialog.dart'; + +class BootstrapPage extends StatelessWidget with WatchItMixin { + const BootstrapPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final l10n = context.l10n; + final model = di(); + + final bootstrap = watchPropertyValue((BootstrapModel m) => m.bootstrap); + final bootstrapState = + watchPropertyValue((BootstrapModel m) => m.bootstrap?.state); + + final wipe = watchPropertyValue((BootstrapModel m) => m.wipe); + final recoveryKeyStored = + watchPropertyValue((BootstrapModel m) => m.recoveryKeyStored); + final recoveryKeyCopied = + watchPropertyValue((BootstrapModel m) => m.recoveryKeyCopied); + final storeInSecureStorage = + watchPropertyValue((BootstrapModel m) => m.storeInSecureStorage); + final key = watchPropertyValue((BootstrapModel m) => m.key); + + final buttons = []; + Widget body = const Progress(); + var titleText = l10n.recoveryKey; + + if (key != null && recoveryKeyStored == false) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + leading: IconButton( + icon: const Icon(YaruIcons.window_close), + onPressed: Navigator.of(context).pop, + ), + title: Text(l10n.recoveryKey), + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + trailing: CircleAvatar( + backgroundColor: Colors.transparent, + child: Icon( + YaruIcons.information, + color: theme.colorScheme.primary, + ), + ), + subtitle: Text(l10n.chatBackupDescription), + ), + const Divider( + height: 32, + thickness: 1, + ), + TextField( + minLines: 2, + maxLines: 4, + readOnly: true, + style: const TextStyle(fontFamily: 'RobotoMono'), + controller: TextEditingController(text: key), + decoration: const InputDecoration( + contentPadding: EdgeInsets.all(16), + suffixIcon: Icon(YaruIcons.key), + ), + ), + const SizedBox(height: 16), + if (model.supportsSecureStorage) + YaruCheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + value: storeInSecureStorage, + onChanged: (v) => model.setStoreInSecureStorage(v ?? false), + title: + Text(model.getSecureStorageLocalizedName(context.l10n)), + subtitle: Text(l10n.storeInSecureStorageDescription), + ), + const SizedBox(height: 16), + YaruCheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + value: recoveryKeyCopied, + onChanged: (b) { + Clipboard.setData(ClipboardData(text: key)); + showSnackBar( + context, + content: Text(l10n.copiedToClipboard), + ); + model.setRecoveryKeyCopied(true); + }, + title: Text(l10n.copyToClipboard), + subtitle: Text(l10n.saveKeyManuallyDescription), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(YaruIcons.checkmark), + label: Text(l10n.next), + onPressed: (recoveryKeyCopied || storeInSecureStorage) + ? () => model.storeRecoveryKey() + : null, + ), + ], + ), + ), + ), + ); + } else { + if (bootstrapState != null && bootstrap != null) { + switch (bootstrapState) { + case BootstrapState.loading: + break; + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.wipeSsss(wipe), + ); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.ignoreBadSecrets(true), + ); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.useExistingSsss(!wipe), + ); + case BootstrapState.askUnlockSsss: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.unlockedSsss(), + ); + case BootstrapState.askNewSsss: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.newSsss(), + ); + case BootstrapState.openExistingSsss: + model.setRecoveryKeyStored(true); + return const OpenExistingSSSSPage(); + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.wipeCrossSigning(wipe), + ); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ), + ); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.wipeOnlineKeyBackup(wipe), + ); + + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback( + (_) => bootstrap.askSetupOnlineKeyBackup(true), + ); + case BootstrapState.error: + titleText = l10n.oopsSomethingWentWrong; + body = const Icon(YaruIcons.error, color: Colors.red, size: 80); + buttons.add( + OutlinedButton( + onPressed: () => Navigator.of(context, rootNavigator: false) + .pop(false), + child: Text(l10n.close), + ), + ); + case BootstrapState.done: + titleText = l10n.everythingReady; + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + YaruIcons.ok_filled, + size: 120, + color: context.colorScheme.success, + ), + const SizedBox(height: 16), + Text( + l10n.yourChatBackupHasBeenSetUp, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(height: 16), + ], + ); + buttons.add( + OutlinedButton( + onPressed: () => Navigator.of(context, rootNavigator: false) + .pop(false), + child: Text(l10n.close), + ), + ); + } + } + } + + return Scaffold( + appBar: YaruDialogTitleBar( + title: Text(titleText), + ), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + body, + const SizedBox(height: 8), + ...buttons, + ], + ), + ), + ); + } +} + +Future showAdaptiveBottomSheet({ + required BuildContext context, + required Widget Function(BuildContext) builder, + bool isDismissible = true, + bool isScrollControlled = true, + double maxHeight = 512, + bool useRootNavigator = true, +}) => + showModalBottomSheet( + context: context, + builder: builder, + // this sadly is ugly on desktops but otherwise breaks `.of(context)` calls + useRootNavigator: useRootNavigator, + isDismissible: isDismissible, + isScrollControlled: isScrollControlled, + constraints: BoxConstraints( + maxHeight: maxHeight, + maxWidth: 400, + ), + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10), + ), + ), + ); + +class OpenExistingSSSSPage extends StatefulWidget + with WatchItStatefulWidgetMixin { + const OpenExistingSSSSPage({super.key}); + + @override + State createState() => _OpenExistingSSSSPageState(); +} + +class _OpenExistingSSSSPageState extends State { + final TextEditingController _recoveryKeyTextEditingController = + TextEditingController(); + + @override + void dispose() { + _recoveryKeyTextEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final l10n = context.l10n; + final model = di(); + + final bootstrap = watchPropertyValue((BootstrapModel m) => m.bootstrap); + final recoveryKeyInputLoading = + watchPropertyValue((BootstrapModel m) => m.recoveryKeyInputLoading); + final recoveryKeyInputError = + watchPropertyValue((BootstrapModel m) => m.recoveryKeyInputError); + + return Scaffold( + body: Center( + child: SizedBox( + width: 400, + child: ListView( + shrinkWrap: true, + children: space( + heightGap: kBigPadding, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + trailing: Icon( + YaruIcons.information, + color: theme.colorScheme.primary, + ), + subtitle: Text( + l10n.pleaseEnterRecoveryKeyDescription, + ), + ), + TextField( + controller: _recoveryKeyTextEditingController, + minLines: 1, + maxLines: 2, + autocorrect: false, + readOnly: recoveryKeyInputLoading, + autofillHints: + recoveryKeyInputLoading ? null : [AutofillHints.password], + style: const TextStyle(fontFamily: 'RobotoMono'), + decoration: InputDecoration( + prefixIcon: const Icon(YaruIcons.key), + labelText: l10n.recoveryKey, + hintText: 'Es** **** **** ****', + errorText: recoveryKeyInputError, + errorMaxLines: 2, + ), + ), + ElevatedButton.icon( + icon: recoveryKeyInputLoading + ? const CircularProgressIndicator.adaptive() + : const Icon(Icons.lock_open_outlined), + label: Text(l10n.unlockOldMessages), + onPressed: recoveryKeyInputLoading + ? null + : () async { + model.setRecoveryKeyInputError(null); + model.setRecoveryKeyInputLoading(true); + try { + final newKey = + _recoveryKeyTextEditingController.text.trim(); + if (newKey.isEmpty == true) return; + await bootstrap?.newSsssKey + ?.unlock(keyOrPassphrase: newKey); + await bootstrap?.openExistingSsss(); + Logs().d('SSSS unlocked'); + if (bootstrap?.encryption.crossSigning.enabled == + true) { + Logs().v( + 'Cross signing is already enabled. Try to self-sign', + ); + try { + await bootstrap?.client.encryption!.crossSigning + .selfSign(recoveryKey: newKey); + Logs().d('Successful selfsigned'); + } catch (e, s) { + Logs().e( + 'Unable to self sign with recovery key after successfully open existing SSSS', + e, + s, + ); + } + } + } on InvalidPassphraseException catch (e) { + if (context.mounted) { + showSnackBar( + context, + content: Text(e.toString()), + ); + } + } on FormatException catch (_) { + model.setRecoveryKeyInputError( + l10n.wrongRecoveryKey, + ); + } catch (e, _) { + if (context.mounted) { + showSnackBar( + context, + content: Text(e.toString()), + ); + } + } finally { + model.setRecoveryKeyInputLoading(false); + } + }, + ), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(12.0), + child: Text(l10n.or), + ), + const Expanded(child: Divider()), + ], + ), + ElevatedButton.icon( + icon: const Icon(YaruIcons.sync), + label: Text(l10n.transferFromAnotherDevice), + onPressed: recoveryKeyInputLoading + ? null + : () async { + final consent = await showOkCancelAlertDialog( + context: context, + title: l10n.verifyOtherDevice, + message: l10n.verifyOtherDeviceDescription, + okLabel: l10n.ok, + cancelLabel: l10n.cancel, + fullyCapitalizedForMaterial: false, + ); + if (consent != OkCancelResult.ok) return; + if (context.mounted) { + final req = await showFutureLoadingDialog( + context: context, + future: di().startKeyVerification, + ); + if (context.mounted) { + if (req.error != null) return; + await KeyVerificationDialog( + request: req.result!, + ).show(context); + } + } + }, + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + icon: const Icon(YaruIcons.trash), + label: Text(l10n.recoveryKeyLost), + onPressed: recoveryKeyInputLoading + ? null + : () async { + final result = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: l10n.recoveryKeyLost, + message: l10n.wipeChatBackup, + okLabel: l10n.ok, + cancelLabel: l10n.cancel, + isDestructiveAction: true, + ); + if (result == OkCancelResult.ok) { + await model.startBootstrap(wipe: true); + } + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/chat/bootstrap/view/key_verification_dialog.dart b/lib/chat/bootstrap/view/key_verification_dialog.dart new file mode 100644 index 0000000..d94670b --- /dev/null +++ b/lib/chat/bootstrap/view/key_verification_dialog.dart @@ -0,0 +1,420 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../l10n/l10n.dart'; +import '../../view/chat_avatar.dart'; + +// TODO: code by fluffy-chat, replace +class KeyVerificationDialog extends StatefulWidget { + Future show(BuildContext context) => showAdaptiveDialog( + context: context, + builder: (context) => this, + barrierDismissible: false, + ); + + final KeyVerification request; + + const KeyVerificationDialog({ + super.key, + required this.request, + }); + + @override + KeyVerificationPageState createState() => KeyVerificationPageState(); +} + +class KeyVerificationPageState extends State { + void Function()? originalOnUpdate; + late final List sasEmoji; + + @override + void initState() { + originalOnUpdate = widget.request.onUpdate; + widget.request.onUpdate = () { + originalOnUpdate?.call(); + setState(() {}); + }; + widget.request.client.getProfileFromUserId(widget.request.userId).then((p) { + profile = p; + setState(() {}); + }); + rootBundle.loadString('assets/sas-emoji.json').then((e) { + sasEmoji = json.decode(e); + setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + widget.request.onUpdate = + originalOnUpdate; // don't want to get updates anymore + if (![KeyVerificationState.error, KeyVerificationState.done] + .contains(widget.request.state)) { + widget.request.cancel('m.user'); + } else {} + super.dispose(); + } + + Profile? profile; + + Future checkInput(String input) async { + if (input.isEmpty) return; + + final valid = await showFutureLoadingDialog( + context: context, + future: () async { + // make sure the loading spinner shows before we test the keys + await Future.delayed(const Duration(milliseconds: 100)); + var valid = false; + try { + await widget.request.openSSSS(keyOrPassphrase: input); + valid = true; + } catch (_) { + valid = false; + } + return valid; + }, + ); + if (valid.error != null && mounted) { + await showOkAlertDialog( + useRootNavigator: false, + context: context, + message: 'incorrectPassphraseOrKey', + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + User? user; + final directChatId = + widget.request.client.getDirectChatFromUserId(widget.request.userId); + if (directChatId != null) { + user = widget.request.client + .getRoomById(directChatId)! + .unsafeGetUserFromMemoryOrFallback(widget.request.userId); + } + final displayName = + user?.calcDisplayname() ?? widget.request.userId.localpart!; + var title = Text(l10n.verifyTitle); + Widget body; + final buttons = []; + + switch (widget.request.state) { + case KeyVerificationState.showQRSuccess: + case KeyVerificationState.confirmQRScan: + throw 'Not implemented'; + case KeyVerificationState.askSSSS: + // prompt the user for their ssss passphrase / key + final textEditingController = TextEditingController(); + String input; + body = Container( + margin: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.askSSSSSign, + style: const TextStyle(fontSize: 20), + ), + Container(height: 10), + TextField( + controller: textEditingController, + autofocus: false, + autocorrect: false, + onSubmitted: (s) { + input = s; + checkInput(input); + }, + minLines: 1, + maxLines: 1, + obscureText: true, + decoration: InputDecoration( + hintText: l10n.passphraseOrKey, + prefixStyle: TextStyle(color: theme.colorScheme.primary), + suffixStyle: TextStyle(color: theme.colorScheme.primary), + border: const OutlineInputBorder(), + ), + ), + ], + ), + ); + buttons.add( + TextButton( + child: Text( + l10n.submit, + ), + onPressed: () => checkInput(textEditingController.text), + ), + ); + buttons.add( + TextButton( + child: Text( + l10n.skip, + ), + onPressed: () => widget.request.openSSSS(skip: true), + ), + ); + case KeyVerificationState.askAccept: + title = Text(l10n.newVerificationRequest); + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + ChatAvatar( + avatarUri: user?.avatarUrl, + ), + const SizedBox(height: 16), + Text( + l10n.askVerificationRequest(displayName), + ), + ], + ); + buttons.add( + TextButton.icon( + icon: const Icon(Icons.close), + style: TextButton.styleFrom(foregroundColor: Colors.red), + label: Text(l10n.reject), + onPressed: () => widget.request.rejectVerification().then((_) { + if (context.mounted) { + Navigator.of(context, rootNavigator: false).pop(); + } + }), + ), + ); + buttons.add( + TextButton.icon( + icon: const Icon(Icons.check), + label: Text(l10n.accept), + onPressed: () => widget.request.acceptVerification(), + ), + ); + case KeyVerificationState.askChoice: + case KeyVerificationState.waitingAccept: + body = Center( + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + ChatAvatar( + avatarUri: user?.avatarUrl, + ), + const SizedBox( + width: 38, + height: 38, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 16), + Text( + l10n.waitingPartnerAcceptRequest, + textAlign: TextAlign.center, + ), + ], + ), + ); + buttons.add( + TextButton.icon( + icon: const Icon(Icons.close), + label: Text(l10n.cancel), + onPressed: () => widget.request.cancel(), + ), + ); + + case KeyVerificationState.askSas: + TextSpan compareWidget; + // maybe add a button to switch between the two and only determine default + // view for if "emoji" is a present sasType or not? + + if (widget.request.sasTypes.contains('emoji')) { + title = Text( + l10n.compareEmojiMatch, + maxLines: 1, + style: const TextStyle(fontSize: 16), + ); + compareWidget = TextSpan( + children: widget.request.sasEmojis + .map((e) => WidgetSpan(child: _Emoji(e, sasEmoji))) + .toList(), + ); + } else { + title = Text(l10n.compareNumbersMatch); + final numbers = widget.request.sasNumbers; + final numbstr = '${numbers[0]}-${numbers[1]}-${numbers[2]}'; + compareWidget = + TextSpan(text: numbstr, style: const TextStyle(fontSize: 40)); + } + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + compareWidget, + textAlign: TextAlign.center, + ), + ], + ); + buttons.add( + TextButton.icon( + icon: const Icon(Icons.close), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + label: Text(l10n.theyDontMatch), + onPressed: () => widget.request.rejectSas(), + ), + ); + buttons.add( + TextButton.icon( + icon: const Icon(Icons.check_outlined), + label: Text(l10n.theyMatch), + onPressed: () => widget.request.acceptSas(), + ), + ); + case KeyVerificationState.waitingSas: + final acceptText = widget.request.sasTypes.contains('emoji') + ? l10n.waitingPartnerEmoji + : l10n.waitingPartnerNumbers; + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(strokeWidth: 2), + const SizedBox(height: 10), + Text( + acceptText, + textAlign: TextAlign.center, + ), + ], + ); + case KeyVerificationState.done: + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle_outlined, + color: Colors.green, + size: 128.0, + ), + const SizedBox(height: 10), + Text( + l10n.verifySuccess, + textAlign: TextAlign.center, + ), + ], + ); + buttons.add( + TextButton( + child: Text( + l10n.close, + ), + onPressed: () { + Navigator.of(context, rootNavigator: false).pop(); + if (context.mounted) { + Navigator.of( + context, + rootNavigator: false, + ).pop(); + } + }, + ), + ); + case KeyVerificationState.error: + title = const Text(''); + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cancel, color: Colors.red, size: 128.0), + const SizedBox(height: 16), + Text( + 'Error ${widget.request.canceledCode}: ${widget.request.canceledReason}', + textAlign: TextAlign.center, + ), + ], + ); + buttons.add( + TextButton( + child: Text( + l10n.close, + ), + onPressed: () => Navigator.of(context, rootNavigator: false).pop(), + ), + ); + } + + return AlertDialog.adaptive( + title: title, + content: Material( + color: Colors.transparent, + child: SizedBox( + height: 256, + width: 256, + child: ListView( + children: [body], + ), + ), + ), + actions: buttons, + ); + } +} + +class _Emoji extends StatelessWidget { + final KeyVerificationEmoji emoji; + final List? sasEmoji; + + const _Emoji(this.emoji, this.sasEmoji); + + String getLocalizedName() { + final sasEmoji = this.sasEmoji; + if (sasEmoji == null) { + // asset is still being loaded + return emoji.name; + } + final translations = Map.from( + sasEmoji[emoji.number]['translated_descriptions'], + ); + translations['en'] = emoji.name; + for (final locale in PlatformDispatcher.instance.locales) { + final wantLocaleParts = locale.toString().split('_'); + final wantLanguage = wantLocaleParts.removeAt(0); + for (final haveLocale in translations.keys) { + final haveLocaleParts = haveLocale.split('_'); + final haveLanguage = haveLocaleParts.removeAt(0); + if (haveLanguage == wantLanguage && + (Set.from(haveLocaleParts)..removeAll(wantLocaleParts)).isEmpty && + (translations[haveLocale]?.isNotEmpty ?? false)) { + return translations[haveLocale]!; + } + } + } + return emoji.name; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji.emoji, style: const TextStyle(fontSize: 50)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text(getLocalizedName()), + ), + const SizedBox(height: 10, width: 5), + ], + ); + } +} diff --git a/lib/chat/chat_download_model.dart b/lib/chat/chat_download_model.dart new file mode 100644 index 0000000..88f6008 --- /dev/null +++ b/lib/chat/chat_download_model.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import 'chat_download_service.dart'; + +class ChatDownloadModel extends SafeChangeNotifier { + ChatDownloadModel({required ChatDownloadService service}) + : _service = service; + + final ChatDownloadService _service; + StreamSubscription? _propertiesChangedSub; + + String? isEventDownloaded(Event event) => _service.isEventDownloaded(event); + Future safeFile(Event event) async => _service.safeFile(event); + + void init() => _propertiesChangedSub = + _service.propertiesChanged.listen((_) => notifyListeners()); + + @override + Future dispose() async { + await _propertiesChangedSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/chat/chat_download_service.dart b/lib/chat/chat_download_service.dart new file mode 100644 index 0000000..bb989b4 --- /dev/null +++ b/lib/chat/chat_download_service.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:matrix/matrix.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../app_config.dart'; + +class ChatDownloadService { + ChatDownloadService({ + required Client client, + required SharedPreferences preferences, + }) : _client = client, + _preferences = preferences; + + // ignore: unused_field + final Client _client; + final SharedPreferences _preferences; + final _propertiesChangedController = StreamController.broadcast(); + Stream get propertiesChanged => _propertiesChangedController.stream; + + String? isEventDownloaded(Event event) { + var path = _preferences.getString(event.eventId); + return path != null && File(path).existsSync() ? path : null; + } + + Future init() async {} + + // TODO: use dio to download then decrypt with client, to show the download progress + Future safeFile(Event event) async { + if (event.attachmentMxcUrl == null) { + return; + } + MatrixFile? file; + String? path; + if (isMobilePlatform) { + file = await event.downloadAndDecryptAttachment(); + path = await FilePicker.platform.saveFile( + fileName: file.name, + bytes: file.bytes, + ); + } else { + final directoryPath = await FilePicker.platform.getDirectoryPath(); + if (directoryPath != null) { + file = await event.downloadAndDecryptAttachment(); + path = '$directoryPath/${file.name}'; + final download = File(path); + await download.writeAsBytes(file.bytes); + } + } + + if (file != null && path != null) { + if (await _preferences.setString(event.eventId, path)) { + _propertiesChangedController.add(true); + } + } + } + + Future dispose() async => _propertiesChangedController.close(); +} diff --git a/lib/chat/chat_model.dart b/lib/chat/chat_model.dart new file mode 100644 index 0000000..484df74 --- /dev/null +++ b/lib/chat/chat_model.dart @@ -0,0 +1,408 @@ +import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import '../common/logging.dart'; +import 'event_x.dart'; +import 'rooms_filter.dart'; + +class ChatModel extends SafeChangeNotifier { + ChatModel({ + required Client client, + }) : _client = client; + + // The matrix dart SDK client + final Client _client; + String? get myUserId => _client.userID; + bool isUserEvent(Event event) => myUserId == event.senderId; + bool get isLogged => _client.isLogged(); + bool get encryptionEnabled => _client.encryptionEnabled; + + // Room management + /// The list of all rooms the user is participating or invited. + List get rooms => _client.rooms; + + /// The list of the archived rooms, call loadArchive() before first call + List get archivedRooms => + _client.archivedRooms.map((e) => e.room).toList(); + + List get filteredRooms { + final theRooms = (archiveActive ? archivedRooms : rooms) + .where(roomsFilter?.filter ?? (e) => true) + .toList(); + if (roomsFilter != RoomsFilter.spaces) { + return theRooms.where((r) => !r.isSpace).toList(); + } + + if (activeSpace == null) { + return []; + } + + return Set.from( + activeSpace!.spaceChildren + .where((r) => r.roomId != null) + .map((r) => _client.getRoomById(r.roomId!)) + .whereNot((r) => r == null) + .where((r) => r!.isArchived == archiveActive), + ).toList(); + } + + // Streams derived from the client onSync stream + /// The unfiltered onSync stream of the [Client] + Stream get syncStream => _client.onSync.stream; + + Stream> getUsersStreamOfJoinedRoom( + Room room, { + List membershipFilter = const [ + Membership.join, + Membership.invite, + Membership.knock, + ], + }) => + syncStream.asyncMap((_) => room.requestParticipants(membershipFilter)); + + /// A stream of [LeftRoomUpdate]s for a specific `roomId` + Stream getLeftRoomStream(String roomId) => + _client.onSync.stream + .where((e) => e.rooms?.leave?.isNotEmpty ?? false) + .map((s) => s.rooms?.leave?[roomId]); + + Stream get joinedUpdateStream => + _client.onSync.stream.where((e) => e.rooms?.join?.isNotEmpty ?? false); + + Stream get inviteUpdateStream => + _client.onSync.stream.where((e) => e.rooms?.invite?.isNotEmpty ?? false); + + Stream getJoinedRoomUpdate(String? roomId) => + joinedUpdateStream.map((e) => e.rooms?.join?[roomId]); + + Stream getJoinedRoomAvatarStream(Room? room) => + getJoinedRoomUpdate(room?.id) + .map( + (e) => e?.ephemeral + ?.firstWhereOrNull((e) => e.type == EventTypes.RoomAvatar), + ) + .map((e) => room?.avatar); + + Stream getInvitedRoomUpdate(String roomId) => + inviteUpdateStream.map((e) => e.rooms?.invite?[roomId]); + + Stream> getTypingUsersStream(Room room) => + getJoinedRoomUpdate(room.id) + .where( + (u) => u?.ephemeral?.any((e) => e.type == 'm.typing') ?? false, + ) + .map( + (u) => + room.typingUsers.where((e) => e.senderId != myUserId).toList(), + ); + + Stream getLastEventStream(Room room) => + getJoinedRoomUpdate(room.id).map((update) => room.lastEvent); + + Stream?> getReadEventsFromSync(Room room) => + getJoinedRoomUpdate(room.id).map( + (update) => update?.timeline?.events + ?.map((e) => Event.fromMatrixEvent(e, room)) + .where((e) => e.receipts.isNotEmpty) + .toList(), + ); + + Stream?> getEventStream(Room room) => + getJoinedRoomUpdate(room.id).map( + (update) => update?.timeline?.events + ?.map((e) => Event.fromMatrixEvent(e, room)) + .toList(), + ); + + Future> getEvents(Room room) async { + final timeline = await room.getTimeline(); + return timeline.events.where((e) => !e.showAsBadge).toList(); + } + + Stream> get spacesStream => joinedUpdateStream + .map((e) => rooms.where((e) => !e.isArchived && e.isSpace).toList()); + + List get notArchivedSpaces => + rooms.where((e) => !e.isArchived && e.isSpace).toList(); + + RoomsFilter? _roomsFilter; + RoomsFilter? get roomsFilter => _roomsFilter; + void setRoomsFilter(RoomsFilter? value) { + if (_roomsFilter == value) { + _roomsFilter = null; + } else { + _roomsFilter = value; + } + setSelectedRoom(null); + } + + Room? _activeSpace; + Room? get activeSpace => _activeSpace; + void setActiveSpace(Room? roomId) { + if (_activeSpace == roomId) { + _activeSpace = null; + } else { + _activeSpace = roomId; + } + notifyListeners(); + } + + bool _processingJoinOrLeave = false; + bool get processingJoinOrLeave => _processingJoinOrLeave; + void _setProcessingJoinOrLeave(bool value) { + if (value == _processingJoinOrLeave) return; + _processingJoinOrLeave = value; + notifyListeners(); + } + + Room? _selectedRoom; + Room? get selectedRoom => _selectedRoom; + Future setSelectedRoom(Room? value) async { + _selectedRoom = value; + if (value == null) { + _roomSearchActive = false; + } + notifyListeners(); + } + + bool _loadingArchive = false; + bool get loadingArchive => _loadingArchive; + _setLoadingArchive(bool value) { + if (value == _loadingArchive) return; + _loadingArchive = value; + notifyListeners(); + } + + bool _archiveActive = false; + bool get archiveActive => _archiveActive; + void toggleArchive() { + setSelectedRoom(null); + _archiveActive = !_archiveActive; + if (_archiveActive) { + _setLoadingArchive(true); + _client.loadArchive().then((_) => _setLoadingArchive(false)); + } else { + _setLoadingArchive(false); + } + notifyListeners(); + } + + Future joinRoom( + Room room, { + required Function(String error) onFail, + bool clear = false, + bool select = true, + }) async { + if (clear) { + setSelectedRoom(null); + } + if (room.membership != Membership.join) { + try { + _setProcessingJoinOrLeave(true); + await room.join(); + if (select) { + setSelectedRoom(room); + } + } on Exception catch (e) { + onFail(e.toString()); + setSelectedRoom(null); + } finally { + _setProcessingJoinOrLeave(false); + } + } else { + if (select) { + setSelectedRoom(room); + } + } + } + + Future createRoom({ + required Function(String error) onFail, + required Function() onSuccess, + String? groupName, + bool enableEncryption = true, + List? invite, + CreateRoomPreset preset = CreateRoomPreset.trustedPrivateChat, + List? initialState, + Visibility? visibility, + HistoryVisibility? historyVisibility, + bool waitForSync = true, + bool groupCall = false, + bool federated = true, + Map? powerLevelContentOverride, + MatrixFile? avatarFile, + }) async { + _setProcessingJoinOrLeave(true); + String? roomId; + + try { + roomId = await _client.createGroupChat( + groupName: groupName, + enableEncryption: enableEncryption, + invite: invite, + preset: preset, + initialState: initialState, + visibility: visibility, + historyVisibility: historyVisibility, + waitForSync: waitForSync, + groupCall: groupCall, + federated: federated, + powerLevelContentOverride: powerLevelContentOverride, + ); + } catch (e, s) { + onFail(e.toString()); + printMessageInDebugMode(e, s); + } finally { + _setProcessingJoinOrLeave(false); + } + if (roomId != null) { + await _client.waitForRoomInSync(roomId, join: true); + final maybeRoom = _client.getRoomById(roomId); + if (maybeRoom != null) { + if (maybeRoom.canChangeStateEvent(EventTypes.RoomAvatar) && + avatarFile?.bytes != null) { + maybeRoom.setAvatar(avatarFile); + } + _archiveActive = false; + setSelectedRoom(maybeRoom); + onSuccess(); + } + } + } + + Future joinDirectChat( + String userId, { + required Function(String error) onFail, + }) async { + _setProcessingJoinOrLeave(true); + + Room? maybeRoom = rooms.firstWhereOrNull( + (e) => + e.getParticipants().length == 2 && + e.getParticipants().any((e) => e.id == myUserId) && + e.getParticipants().any((e) => e.id == userId), + ); + + if (maybeRoom == null) { + String? maybeId; + try { + maybeId = await _client.startDirectChat( + userId, + preset: CreateRoomPreset.privateChat, + ); + } on Exception catch (e) { + onFail(e.toString()); + } + + if (maybeId != null) { + maybeRoom = Room(id: maybeId, client: _client); + } + } + + if (maybeRoom != null) { + try { + await joinRoom(maybeRoom, onFail: onFail); + } on Exception catch (e) { + onFail(e.toString()); + } finally { + _setProcessingJoinOrLeave(false); + } + } + + _setProcessingJoinOrLeave(false); + } + + Future joinAndSelectRoomByChunk( + PublicRoomsChunk chunk, { + required Function(String error) onFail, + }) async { + _setProcessingJoinOrLeave(true); + + final knock = chunk.joinRule == 'knock'; + + String? roomId; + try { + if (_client.getRoomById(chunk.roomId) != null) { + roomId = chunk.roomId; + } + roomId = knock + ? await _client.knockRoom(chunk.roomId) + : await _client.joinRoom(chunk.roomId); + + if (!knock && _client.getRoomById(roomId) == null) { + await _client.waitForRoomInSync(roomId); + } + } on Exception catch (e) { + onFail(e.toString()); + } finally { + if (roomId != null) { + final room = _client.getRoomById(roomId); + if (room != null && !room.isSpace) { + setSelectedRoom(room); + } + } + _setProcessingJoinOrLeave(false); + } + } + + Future leaveSelectedRoom({ + required Function(String error) onFail, + Room? room, + bool forget = false, + }) async { + _setProcessingJoinOrLeave(true); + try { + await (room ?? _selectedRoom)?.leave(); + if (forget) { + await (room ?? _selectedRoom)?.forget(); + } + if (room == activeSpace) { + setActiveSpace(null); + } + } on Exception catch (e) { + onFail(e.toString()); + } finally { + setSelectedRoom(null); + _setProcessingJoinOrLeave(false); + } + } + + // TIMELINES + + bool _updatingTimeline = false; + bool get updatingTimeline => _updatingTimeline; + void setUpdatingTimeline(bool value) { + if (value == _updatingTimeline) return; + _updatingTimeline = value; + notifyListeners(); + } + + Future requestHistory( + Timeline timeline, { + int historyCount = Room.defaultHistoryCount, + StateFilter? filter, + bool notify = true, + }) async { + if (notify) { + setUpdatingTimeline(true); + } + if (timeline.isRequestingHistory) { + setUpdatingTimeline(false); + return; + } + await timeline.requestHistory(filter: filter, historyCount: historyCount); + if (notify) { + setUpdatingTimeline(false); + } + } + + bool _roomSearchActive = false; + bool get roomSearchActive => _roomSearchActive; + void toggleRoomSearch({bool? value}) { + bool theValue = value ?? !_roomSearchActive; + if (theValue == _roomSearchActive) return; + _roomSearchActive = theValue; + notifyListeners(); + } +} diff --git a/lib/chat/data/emojis.dart b/lib/chat/data/emojis.dart new file mode 100644 index 0000000..dad2cdc --- /dev/null +++ b/lib/chat/data/emojis.dart @@ -0,0 +1,1912 @@ +const emojis = [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '🙃', + '🫠', + '😉', + '😊', + '😇', + '🥰', + '😍', + '🤩', + '😘', + '😗', + '☺', + '😚', + '😙', + '🥲', + '😋', + '😛', + '😜', + '🤪', + '😝', + '🤑', + '🤗', + '🤭', + '🫢', + '🫣', + '🤫', + '🤔', + '🫡', + '🤐', + '🤨', + '😐', + '😑', + '😶', + '🫥', + '😶‍🌫️', + '😏', + '😒', + '🙄', + '😬', + '😮‍💨', + '🤥', + '🫨', + '🙂‍↔️', + '🙂‍↕️', + '😌', + '😔', + '😪', + '🤤', + '😴', + '🫩', + '😷', + '🤒', + '🤕', + '🤢', + '🤮', + '🤧', + '🥵', + '🥶', + '🥴', + '😵', + '😵‍💫', + '🤯', + '🤠', + '🥳', + '🥸', + '😎', + '🤓', + '🧐', + '😕', + '🫤', + '😟', + '🙁', + '☹', + '😮', + '😯', + '😲', + '😳', + '🥺', + '🥹', + '😦', + '😧', + '😨', + '😰', + '😥', + '😢', + '😭', + '😱', + '😖', + '😣', + '😞', + '😓', + '😩', + '😫', + '🥱', + '😤', + '😡', + '😠', + '🤬', + '😈', + '👿', + '💀', + '☠', + '💩', + '🤡', + '👹', + '👺', + '👻', + '👽', + '👾', + '🤖', + '😺', + '😸', + '😹', + '😻', + '😼', + '😽', + '🙀', + '😿', + '😾', + '🙈', + '🙉', + '🙊', + '💌', + '💘', + '💝', + '💖', + '💗', + '💓', + '💞', + '💕', + '💟', + '❣', + '💔', + '❤️‍🔥', + '❤️‍🩹', + '❤', + '🩷', + '🧡', + '💛', + '💚', + '💙', + '🩵', + '💜', + '🤎', + '🖤', + '🩶', + '🤍', + '💋', + '💯', + '💢', + '💥', + '💫', + '💦', + '💨', + '🕳', + '💬', + '👁️‍🗨️', + '🗨', + '🗯', + '💭', + '💤', + '👋', + '🤚', + '🖐', + '✋', + '🖖', + '🫱', + '🫲', + '🫳', + '🫴', + '🫷', + '🫸', + '👌', + '🤌', + '🤏', + '✌', + '🤞', + '🫰', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👆', + '🖕', + '👇', + '☝', + '🫵', + '👍', + '👎', + '✊', + '👊', + '🤛', + '🤜', + '👏', + '🙌', + '🫶', + '👐', + '🤲', + '🤝', + '🙏', + '✍', + '💅', + '🤳', + '💪', + '🦾', + '🦿', + '🦵', + '🦶', + '👂', + '🦻', + '👃', + '🧠', + '🫀', + '🫁', + '🦷', + '🦴', + '👀', + '👁', + '👅', + '👄', + '🫦', + '👶', + '🧒', + '👦', + '👧', + '🧑', + '👱', + '👨', + '🧔', + '🧔‍♂️', + '🧔‍♀️', + '👨‍🦰', + '👨‍🦱', + '👨‍🦳', + '👨‍🦲', + '👩', + '👩‍🦰', + '🧑‍🦰', + '👩‍🦱', + '🧑‍🦱', + '👩‍🦳', + '🧑‍🦳', + '👩‍🦲', + '🧑‍🦲', + '👱‍♀️', + '👱‍♂️', + '🧓', + '👴', + '👵', + '🙍', + '🙍‍♂️', + '🙍‍♀️', + '🙎', + '🙎‍♂️', + '🙎‍♀️', + '🙅', + '🙅‍♂️', + '🙅‍♀️', + '🙆', + '🙆‍♂️', + '🙆‍♀️', + '💁', + '💁‍♂️', + '💁‍♀️', + '🙋', + '🙋‍♂️', + '🙋‍♀️', + '🧏', + '🧏‍♂️', + '🧏‍♀️', + '🙇', + '🙇‍♂️', + '🙇‍♀️', + '🤦', + '🤦‍♂️', + '🤦‍♀️', + '🤷', + '🤷‍♂️', + '🤷‍♀️', + '🧑‍⚕️', + '👨‍⚕️', + '👩‍⚕️', + '🧑‍🎓', + '👨‍🎓', + '👩‍🎓', + '🧑‍🏫', + '👨‍🏫', + '👩‍🏫', + '🧑‍⚖️', + '👨‍⚖️', + '👩‍⚖️', + '🧑‍🌾', + '👨‍🌾', + '👩‍🌾', + '🧑‍🍳', + '👨‍🍳', + '👩‍🍳', + '🧑‍🔧', + '👨‍🔧', + '👩‍🔧', + '🧑‍🏭', + '👨‍🏭', + '👩‍🏭', + '🧑‍💼', + '👨‍💼', + '👩‍💼', + '🧑‍🔬', + '👨‍🔬', + '👩‍🔬', + '🧑‍💻', + '👨‍💻', + '👩‍💻', + '🧑‍🎤', + '👨‍🎤', + '👩‍🎤', + '🧑‍🎨', + '👨‍🎨', + '👩‍🎨', + '🧑‍✈️', + '👨‍✈️', + '👩‍✈️', + '🧑‍🚀', + '👨‍🚀', + '👩‍🚀', + '🧑‍🚒', + '👨‍🚒', + '👩‍🚒', + '👮', + '👮‍♂️', + '👮‍♀️', + '🕵', + '🕵️‍♂️', + '🕵️‍♀️', + '💂', + '💂‍♂️', + '💂‍♀️', + '🥷', + '👷', + '👷‍♂️', + '👷‍♀️', + '🫅', + '🤴', + '👸', + '👳', + '👳‍♂️', + '👳‍♀️', + '👲', + '🧕', + '🤵', + '🤵‍♂️', + '🤵‍♀️', + '👰', + '👰‍♂️', + '👰‍♀️', + '🤰', + '🫃', + '🫄', + '🤱', + '👩‍🍼', + '👨‍🍼', + '🧑‍🍼', + '👼', + '🎅', + '🤶', + '🧑‍🎄', + '🦸', + '🦸‍♂️', + '🦸‍♀️', + '🦹', + '🦹‍♂️', + '🦹‍♀️', + '🧙', + '🧙‍♂️', + '🧙‍♀️', + '🧚', + '🧚‍♂️', + '🧚‍♀️', + '🧛', + '🧛‍♂️', + '🧛‍♀️', + '🧜', + '🧜‍♂️', + '🧜‍♀️', + '🧝', + '🧝‍♂️', + '🧝‍♀️', + '🧞', + '🧞‍♂️', + '🧞‍♀️', + '🧟', + '🧟‍♂️', + '🧟‍♀️', + '🧌', + '💆', + '💆‍♂️', + '💆‍♀️', + '💇', + '💇‍♂️', + '💇‍♀️', + '🚶', + '🚶‍♂️', + '🚶‍♀️', + '🚶‍➡️', + '🚶‍♀️‍➡️', + '🚶‍♂️‍➡️', + '🧍', + '🧍‍♂️', + '🧍‍♀️', + '🧎', + '🧎‍♂️', + '🧎‍♀️', + '🧎‍➡️', + '🧎‍♀️‍➡️', + '🧎‍♂️‍➡️', + '🧑‍🦯', + '🧑‍🦯‍➡️', + '👨‍🦯', + '👨‍🦯‍➡️', + '👩‍🦯', + '👩‍🦯‍➡️', + '🧑‍🦼', + '🧑‍🦼‍➡️', + '👨‍🦼', + '👨‍🦼‍➡️', + '👩‍🦼', + '👩‍🦼‍➡️', + '🧑‍🦽', + '🧑‍🦽‍➡️', + '👨‍🦽', + '👨‍🦽‍➡️', + '👩‍🦽', + '👩‍🦽‍➡️', + '🏃', + '🏃‍♂️', + '🏃‍♀️', + '🏃‍➡️', + '🏃‍♀️‍➡️', + '🏃‍♂️‍➡️', + '💃', + '🕺', + '🕴', + '👯', + '👯‍♂️', + '👯‍♀️', + '🧖', + '🧖‍♂️', + '🧖‍♀️', + '🧗', + '🧗‍♂️', + '🧗‍♀️', + '🤺', + '🏇', + '⛷', + '🏂', + '🏌', + '🏌️‍♂️', + '🏌️‍♀️', + '🏄', + '🏄‍♂️', + '🏄‍♀️', + '🚣', + '🚣‍♂️', + '🚣‍♀️', + '🏊', + '🏊‍♂️', + '🏊‍♀️', + '⛹', + '⛹️‍♂️', + '⛹️‍♀️', + '🏋', + '🏋️‍♂️', + '🏋️‍♀️', + '🚴', + '🚴‍♂️', + '🚴‍♀️', + '🚵', + '🚵‍♂️', + '🚵‍♀️', + '🤸', + '🤸‍♂️', + '🤸‍♀️', + '🤼', + '🤼‍♂️', + '🤼‍♀️', + '🤽', + '🤽‍♂️', + '🤽‍♀️', + '🤾', + '🤾‍♂️', + '🤾‍♀️', + '🤹', + '🤹‍♂️', + '🤹‍♀️', + '🧘', + '🧘‍♂️', + '🧘‍♀️', + '🛀', + '🛌', + '🧑‍🤝‍🧑', + '👭', + '👫', + '👬', + '💏', + '👩‍❤️‍💋‍👨', + '👨‍❤️‍💋‍👨', + '👩‍❤️‍💋‍👩', + '💑', + '👩‍❤️‍👨', + '👨‍❤️‍👨', + '👩‍❤️‍👩', + '👨‍👩‍👦', + '👨‍👩‍👧', + '👨‍👩‍👧‍👦', + '👨‍👩‍👦‍👦', + '👨‍👩‍👧‍👧', + '👨‍👨‍👦', + '👨‍👨‍👧', + '👨‍👨‍👧‍👦', + '👨‍👨‍👦‍👦', + '👨‍👨‍👧‍👧', + '👩‍👩‍👦', + '👩‍👩‍👧', + '👩‍👩‍👧‍👦', + '👩‍👩‍👦‍👦', + '👩‍👩‍👧‍👧', + '👨‍👦', + '👨‍👦‍👦', + '👨‍👧', + '👨‍👧‍👦', + '👨‍👧‍👧', + '👩‍👦', + '👩‍👦‍👦', + '👩‍👧', + '👩‍👧‍👦', + '👩‍👧‍👧', + '🗣', + '👤', + '👥', + '🫂', + '👪', + '🧑‍🧑‍🧒', + '🧑‍🧑‍🧒‍🧒', + '🧑‍🧒', + '🧑‍🧒‍🧒', + '👣', + '🫆', + '🦰', + '🦱', + '🦳', + '🦲', + '🐵', + '🐒', + '🦍', + '🦧', + '🐶', + '🐕', + '🦮', + '🐕‍🦺', + '🐩', + '🐺', + '🦊', + '🦝', + '🐱', + '🐈', + '🐈‍⬛', + '🦁', + '🐯', + '🐅', + '🐆', + '🐴', + '🫎', + '🫏', + '🐎', + '🦄', + '🦓', + '🦌', + '🦬', + '🐮', + '🐂', + '🐃', + '🐄', + '🐷', + '🐖', + '🐗', + '🐽', + '🐏', + '🐑', + '🐐', + '🐪', + '🐫', + '🦙', + '🦒', + '🐘', + '🦣', + '🦏', + '🦛', + '🐭', + '🐁', + '🐀', + '🐹', + '🐰', + '🐇', + '🐿', + '🦫', + '🦔', + '🦇', + '🐻', + '🐻‍❄️', + '🐨', + '🐼', + '🦥', + '🦦', + '🦨', + '🦘', + '🦡', + '🐾', + '🦃', + '🐔', + '🐓', + '🐣', + '🐤', + '🐥', + '🐦', + '🐧', + '🕊', + '🦅', + '🦆', + '🦢', + '🦉', + '🦤', + '🪶', + '🦩', + '🦚', + '🦜', + '🪽', + '🐦‍⬛', + '🪿', + '🐦‍🔥', + '🐸', + '🐊', + '🐢', + '🦎', + '🐍', + '🐲', + '🐉', + '🦕', + '🦖', + '🐳', + '🐋', + '🐬', + '🦭', + '🐟', + '🐠', + '🐡', + '🦈', + '🐙', + '🐚', + '🪸', + '🪼', + '🦀', + '🦞', + '🦐', + '🦑', + '🦪', + '🐌', + '🦋', + '🐛', + '🐜', + '🐝', + '🪲', + '🐞', + '🦗', + '🪳', + '🕷', + '🕸', + '🦂', + '🦟', + '🪰', + '🪱', + '🦠', + '💐', + '🌸', + '💮', + '🪷', + '🏵', + '🌹', + '🥀', + '🌺', + '🌻', + '🌼', + '🌷', + '🪻', + '🌱', + '🪴', + '🌲', + '🌳', + '🌴', + '🌵', + '🌾', + '🌿', + '☘', + '🍀', + '🍁', + '🍂', + '🍃', + '🪹', + '🪺', + '🍄', + '🪾', + '🍇', + '🍈', + '🍉', + '🍊', + '🍋', + '🍋‍🟩', + '🍌', + '🍍', + '🥭', + '🍎', + '🍏', + '🍐', + '🍑', + '🍒', + '🍓', + '🫐', + '🥝', + '🍅', + '🫒', + '🥥', + '🥑', + '🍆', + '🥔', + '🥕', + '🌽', + '🌶', + '🫑', + '🥒', + '🥬', + '🥦', + '🧄', + '🧅', + '🥜', + '🫘', + '🌰', + '🫚', + '🫛', + '🍄‍🟫', + '🫜', + '🍞', + '🥐', + '🥖', + '🫓', + '🥨', + '🥯', + '🥞', + '🧇', + '🧀', + '🍖', + '🍗', + '🥩', + '🥓', + '🍔', + '🍟', + '🍕', + '🌭', + '🥪', + '🌮', + '🌯', + '🫔', + '🥙', + '🧆', + '🥚', + '🍳', + '🥘', + '🍲', + '🫕', + '🥣', + '🥗', + '🍿', + '🧈', + '🧂', + '🥫', + '🍱', + '🍘', + '🍙', + '🍚', + '🍛', + '🍜', + '🍝', + '🍠', + '🍢', + '🍣', + '🍤', + '🍥', + '🥮', + '🍡', + '🥟', + '🥠', + '🥡', + '🍦', + '🍧', + '🍨', + '🍩', + '🍪', + '🎂', + '🍰', + '🧁', + '🥧', + '🍫', + '🍬', + '🍭', + '🍮', + '🍯', + '🍼', + '🥛', + '☕', + '🫖', + '🍵', + '🍶', + '🍾', + '🍷', + '🍸', + '🍹', + '🍺', + '🍻', + '🥂', + '🥃', + '🫗', + '🥤', + '🧋', + '🧃', + '🧉', + '🧊', + '🥢', + '🍽', + '🍴', + '🥄', + '🔪', + '🫙', + '🏺', + '🌍', + '🌎', + '🌏', + '🌐', + '🗺', + '🗾', + '🧭', + '🏔', + '⛰', + '🌋', + '🗻', + '🏕', + '🏖', + '🏜', + '🏝', + '🏞', + '🏟', + '🏛', + '🏗', + '🧱', + '🪨', + '🪵', + '🛖', + '🏘', + '🏚', + '🏠', + '🏡', + '🏢', + '🏣', + '🏤', + '🏥', + '🏦', + '🏨', + '🏩', + '🏪', + '🏫', + '🏬', + '🏭', + '🏯', + '🏰', + '💒', + '🗼', + '🗽', + '⛪', + '🕌', + '🛕', + '🕍', + '⛩', + '🕋', + '⛲', + '⛺', + '🌁', + '🌃', + '🏙', + '🌄', + '🌅', + '🌆', + '🌇', + '🌉', + '♨', + '🎠', + '🛝', + '🎡', + '🎢', + '💈', + '🎪', + '🚂', + '🚃', + '🚄', + '🚅', + '🚆', + '🚇', + '🚈', + '🚉', + '🚊', + '🚝', + '🚞', + '🚋', + '🚌', + '🚍', + '🚎', + '🚐', + '🚑', + '🚒', + '🚓', + '🚔', + '🚕', + '🚖', + '🚗', + '🚘', + '🚙', + '🛻', + '🚚', + '🚛', + '🚜', + '🏎', + '🏍', + '🛵', + '🦽', + '🦼', + '🛺', + '🚲', + '🛴', + '🛹', + '🛼', + '🚏', + '🛣', + '🛤', + '🛢', + '⛽', + '🛞', + '🚨', + '🚥', + '🚦', + '🛑', + '🚧', + '⚓', + '🛟', + '⛵', + '🛶', + '🚤', + '🛳', + '⛴', + '🛥', + '🚢', + '✈', + '🛩', + '🛫', + '🛬', + '🪂', + '💺', + '🚁', + '🚟', + '🚠', + '🚡', + '🛰', + '🚀', + '🛸', + '🛎', + '🧳', + '⌛', + '⏳', + '⌚', + '⏰', + '⏱', + '⏲', + '🕰', + '🕛', + '🕧', + '🕐', + '🕜', + '🕑', + '🕝', + '🕒', + '🕞', + '🕓', + '🕟', + '🕔', + '🕠', + '🕕', + '🕡', + '🕖', + '🕢', + '🕗', + '🕣', + '🕘', + '🕤', + '🕙', + '🕥', + '🕚', + '🕦', + '🌑', + '🌒', + '🌓', + '🌔', + '🌕', + '🌖', + '🌗', + '🌘', + '🌙', + '🌚', + '🌛', + '🌜', + '🌡', + '☀', + '🌝', + '🌞', + '🪐', + '⭐', + '🌟', + '🌠', + '🌌', + '☁', + '⛅', + '⛈', + '🌤', + '🌥', + '🌦', + '🌧', + '🌨', + '🌩', + '🌪', + '🌫', + '🌬', + '🌀', + '🌈', + '🌂', + '☂', + '☔', + '⛱', + '⚡', + '❄', + '☃', + '⛄', + '☄', + '🔥', + '💧', + '🌊', + '🎃', + '🎄', + '🎆', + '🎇', + '🧨', + '✨', + '🎈', + '🎉', + '🎊', + '🎋', + '🎍', + '🎎', + '🎏', + '🎐', + '🎑', + '🧧', + '🎀', + '🎁', + '🎗', + '🎟', + '🎫', + '🎖', + '🏆', + '🏅', + '🥇', + '🥈', + '🥉', + '⚽', + '⚾', + '🥎', + '🏀', + '🏐', + '🏈', + '🏉', + '🎾', + '🥏', + '🎳', + '🏏', + '🏑', + '🏒', + '🥍', + '🏓', + '🏸', + '🥊', + '🥋', + '🥅', + '⛳', + '⛸', + '🎣', + '🤿', + '🎽', + '🎿', + '🛷', + '🥌', + '🎯', + '🪀', + '🪁', + '🔫', + '🎱', + '🔮', + '🪄', + '🎮', + '🕹', + '🎰', + '🎲', + '🧩', + '🧸', + '🪅', + '🪩', + '🪆', + '♠', + '♥', + '♦', + '♣', + '♟', + '🃏', + '🀄', + '🎴', + '🎭', + '🖼', + '🎨', + '🧵', + '🪡', + '🧶', + '🪢', + '👓', + '🕶', + '🥽', + '🥼', + '🦺', + '👔', + '👕', + '👖', + '🧣', + '🧤', + '🧥', + '🧦', + '👗', + '👘', + '🥻', + '🩱', + '🩲', + '🩳', + '👙', + '👚', + '🪭', + '👛', + '👜', + '👝', + '🛍', + '🎒', + '🩴', + '👞', + '👟', + '🥾', + '🥿', + '👠', + '👡', + '🩰', + '👢', + '🪮', + '👑', + '👒', + '🎩', + '🎓', + '🧢', + '🪖', + '⛑', + '📿', + '💄', + '💍', + '💎', + '🔇', + '🔈', + '🔉', + '🔊', + '📢', + '📣', + '📯', + '🔔', + '🔕', + '🎼', + '🎵', + '🎶', + '🎙', + '🎚', + '🎛', + '🎤', + '🎧', + '📻', + '🎷', + '🪗', + '🎸', + '🎹', + '🎺', + '🎻', + '🪕', + '🥁', + '🪘', + '🪇', + '🪈', + '🪉', + '📱', + '📲', + '☎', + '📞', + '📟', + '📠', + '🔋', + '🪫', + '🔌', + '💻', + '🖥', + '🖨', + '⌨', + '🖱', + '🖲', + '💽', + '💾', + '💿', + '📀', + '🧮', + '🎥', + '🎞', + '📽', + '🎬', + '📺', + '📷', + '📸', + '📹', + '📼', + '🔍', + '🔎', + '🕯', + '💡', + '🔦', + '🏮', + '🪔', + '📔', + '📕', + '📖', + '📗', + '📘', + '📙', + '📚', + '📓', + '📒', + '📃', + '📜', + '📄', + '📰', + '🗞', + '📑', + '🔖', + '🏷', + '💰', + '🪙', + '💴', + '💵', + '💶', + '💷', + '💸', + '💳', + '🧾', + '💹', + '✉', + '📧', + '📨', + '📩', + '📤', + '📥', + '📦', + '📫', + '📪', + '📬', + '📭', + '📮', + '🗳', + '✏', + '✒', + '🖋', + '🖊', + '🖌', + '🖍', + '📝', + '💼', + '📁', + '📂', + '🗂', + '📅', + '📆', + '🗒', + '🗓', + '📇', + '📈', + '📉', + '📊', + '📋', + '📌', + '📍', + '📎', + '🖇', + '📏', + '📐', + '✂', + '🗃', + '🗄', + '🗑', + '🔒', + '🔓', + '🔏', + '🔐', + '🔑', + '🗝', + '🔨', + '🪓', + '⛏', + '⚒', + '🛠', + '🗡', + '⚔', + '💣', + '🪃', + '🏹', + '🛡', + '🪚', + '🔧', + '🪛', + '🔩', + '⚙', + '🗜', + '⚖', + '🦯', + '🔗', + '⛓️‍💥', + '⛓', + '🪝', + '🧰', + '🧲', + '🪜', + '🪏', + '⚗', + '🧪', + '🧫', + '🧬', + '🔬', + '🔭', + '📡', + '💉', + '🩸', + '💊', + '🩹', + '🩼', + '🩺', + '🩻', + '🚪', + '🛗', + '🪞', + '🪟', + '🛏', + '🛋', + '🪑', + '🚽', + '🪠', + '🚿', + '🛁', + '🪤', + '🪒', + '🧴', + '🧷', + '🧹', + '🧺', + '🧻', + '🪣', + '🧼', + '🫧', + '🪥', + '🧽', + '🧯', + '🛒', + '🚬', + '⚰', + '🪦', + '⚱', + '🧿', + '🪬', + '🗿', + '🪧', + '🪪', + '🏧', + '🚮', + '🚰', + '♿', + '🚹', + '🚺', + '🚻', + '🚼', + '🚾', + '🛂', + '🛃', + '🛄', + '🛅', + '⚠', + '🚸', + '⛔', + '🚫', + '🚳', + '🚭', + '🚯', + '🚱', + '🚷', + '📵', + '🔞', + '☢', + '☣', + '⬆', + '↗', + '➡', + '↘', + '⬇', + '↙', + '⬅', + '↖', + '↕', + '↔', + '↩', + '↪', + '⤴', + '⤵', + '🔃', + '🔄', + '🔙', + '🔚', + '🔛', + '🔜', + '🔝', + '🛐', + '⚛', + '🕉', + '✡', + '☸', + '☯', + '✝', + '☦', + '☪', + '☮', + '🕎', + '🔯', + '🪯', + '♈', + '♉', + '♊', + '♋', + '♌', + '♍', + '♎', + '♏', + '♐', + '♑', + '♒', + '♓', + '⛎', + '🔀', + '🔁', + '🔂', + '▶', + '⏩', + '⏭', + '⏯', + '◀', + '⏪', + '⏮', + '🔼', + '⏫', + '🔽', + '⏬', + '⏸', + '⏹', + '⏺', + '⏏', + '🎦', + '🔅', + '🔆', + '📶', + '🛜', + '📳', + '📴', + '♀', + '♂', + '⚧', + '✖', + '➕', + '➖', + '➗', + '🟰', + '♾', + '‼', + '⁉', + '❓', + '❔', + '❕', + '❗', + '〰', + '💱', + '💲', + '⚕', + '♻', + '⚜', + '🔱', + '📛', + '🔰', + '⭕', + '✅', + '☑', + '✔', + '❌', + '❎', + '➰', + '➿', + '〽', + '✳', + '✴', + '❇', + '©', + '®', + '™', + '🫟', + '#️⃣', + '*️⃣', + '0️⃣', + '1️⃣', + '2️⃣', + '3️⃣', + '4️⃣', + '5️⃣', + '6️⃣', + '7️⃣', + '8️⃣', + '9️⃣', + '🔟', + '🔠', + '🔡', + '🔢', + '🔣', + '🔤', + '🅰', + '🆎', + '🅱', + '🆑', + '🆒', + '🆓', + 'ℹ', + '🆔', + 'Ⓜ', + '🆕', + '🆖', + '🅾', + '🆗', + '🅿', + '🆘', + '🆙', + '🆚', + '🈁', + '🈂', + '🈷', + '🈶', + '🈯', + '🉐', + '🈹', + '🈚', + '🈲', + '🉑', + '🈸', + '🈴', + '🈳', + '㊗', + '㊙', + '🈺', + '🈵', + '🔴', + '🟠', + '🟡', + '🟢', + '🔵', + '🟣', + '🟤', + '⚫', + '⚪', + '🟥', + '🟧', + '🟨', + '🟩', + '🟦', + '🟪', + '🟫', + '⬛', + '⬜', + '◼', + '◻', + '◾', + '◽', + '▪', + '▫', + '🔶', + '🔷', + '🔸', + '🔹', + '🔺', + '🔻', + '💠', + '🔘', + '🔳', + '🔲', + '🏁', + '🚩', + '🎌', + '🏴', + '🏳', + '🏳️‍🌈', + '🏳️‍⚧️', + '🏴‍☠️', + '🇦🇨', + '🇦🇩', + '🇦🇪', + '🇦🇫', + '🇦🇬', + '🇦🇮', + '🇦🇱', + '🇦🇲', + '🇦🇴', + '🇦🇶', + '🇦🇷', + '🇦🇸', + '🇦🇹', + '🇦🇺', + '🇦🇼', + '🇦🇽', + '🇦🇿', + '🇧🇦', + '🇧🇧', + '🇧🇩', + '🇧🇪', + '🇧🇫', + '🇧🇬', + '🇧🇭', + '🇧🇮', + '🇧🇯', + '🇧🇱', + '🇧🇲', + '🇧🇳', + '🇧🇴', + '🇧🇶', + '🇧🇷', + '🇧🇸', + '🇧🇹', + '🇧🇻', + '🇧🇼', + '🇧🇾', + '🇧🇿', + '🇨🇦', + '🇨🇨', + '🇨🇩', + '🇨🇫', + '🇨🇬', + '🇨🇭', + '🇨🇮', + '🇨🇰', + '🇨🇱', + '🇨🇲', + '🇨🇳', + '🇨🇴', + '🇨🇵', + '🇨🇶', + '🇨🇷', + '🇨🇺', + '🇨🇻', + '🇨🇼', + '🇨🇽', + '🇨🇾', + '🇨🇿', + '🇩🇪', + '🇩🇬', + '🇩🇯', + '🇩🇰', + '🇩🇲', + '🇩🇴', + '🇩🇿', + '🇪🇦', + '🇪🇨', + '🇪🇪', + '🇪🇬', + '🇪🇭', + '🇪🇷', + '🇪🇸', + '🇪🇹', + '🇪🇺', + '🇫🇮', + '🇫🇯', + '🇫🇰', + '🇫🇲', + '🇫🇴', + '🇫🇷', + '🇬🇦', + '🇬🇧', + '🇬🇩', + '🇬🇪', + '🇬🇫', + '🇬🇬', + '🇬🇭', + '🇬🇮', + '🇬🇱', + '🇬🇲', + '🇬🇳', + '🇬🇵', + '🇬🇶', + '🇬🇷', + '🇬🇸', + '🇬🇹', + '🇬🇺', + '🇬🇼', + '🇬🇾', + '🇭🇰', + '🇭🇲', + '🇭🇳', + '🇭🇷', + '🇭🇹', + '🇭🇺', + '🇮🇨', + '🇮🇩', + '🇮🇪', + '🇮🇱', + '🇮🇲', + '🇮🇳', + '🇮🇴', + '🇮🇶', + '🇮🇷', + '🇮🇸', + '🇮🇹', + '🇯🇪', + '🇯🇲', + '🇯🇴', + '🇯🇵', + '🇰🇪', + '🇰🇬', + '🇰🇭', + '🇰🇮', + '🇰🇲', + '🇰🇳', + '🇰🇵', + '🇰🇷', + '🇰🇼', + '🇰🇾', + '🇰🇿', + '🇱🇦', + '🇱🇧', + '🇱🇨', + '🇱🇮', + '🇱🇰', + '🇱🇷', + '🇱🇸', + '🇱🇹', + '🇱🇺', + '🇱🇻', + '🇱🇾', + '🇲🇦', + '🇲🇨', + '🇲🇩', + '🇲🇪', + '🇲🇫', + '🇲🇬', + '🇲🇭', + '🇲🇰', + '🇲🇱', + '🇲🇲', + '🇲🇳', + '🇲🇴', + '🇲🇵', + '🇲🇶', + '🇲🇷', + '🇲🇸', + '🇲🇹', + '🇲🇺', + '🇲🇻', + '🇲🇼', + '🇲🇽', + '🇲🇾', + '🇲🇿', + '🇳🇦', + '🇳🇨', + '🇳🇪', + '🇳🇫', + '🇳🇬', + '🇳🇮', + '🇳🇱', + '🇳🇴', + '🇳🇵', + '🇳🇷', + '🇳🇺', + '🇳🇿', + '🇴🇲', + '🇵🇦', + '🇵🇪', + '🇵🇫', + '🇵🇬', + '🇵🇭', + '🇵🇰', + '🇵🇱', + '🇵🇲', + '🇵🇳', + '🇵🇷', + '🇵🇸', + '🇵🇹', + '🇵🇼', + '🇵🇾', + '🇶🇦', + '🇷🇪', + '🇷🇴', + '🇷🇸', + '🇷🇺', + '🇷🇼', + '🇸🇦', + '🇸🇧', + '🇸🇨', + '🇸🇩', + '🇸🇪', + '🇸🇬', + '🇸🇭', + '🇸🇮', + '🇸🇯', + '🇸🇰', + '🇸🇱', + '🇸🇲', + '🇸🇳', + '🇸🇴', + '🇸🇷', + '🇸🇸', + '🇸🇹', + '🇸🇻', + '🇸🇽', + '🇸🇾', + '🇸🇿', + '🇹🇦', + '🇹🇨', + '🇹🇩', + '🇹🇫', + '🇹🇬', + '🇹🇭', + '🇹🇯', + '🇹🇰', + '🇹🇱', + '🇹🇲', + '🇹🇳', + '🇹🇴', + '🇹🇷', + '🇹🇹', + '🇹🇻', + '🇹🇼', + '🇹🇿', + '🇺🇦', + '🇺🇬', + '🇺🇲', + '🇺🇳', + '🇺🇸', + '🇺🇾', + '🇺🇿', + '🇻🇦', + '🇻🇨', + '🇻🇪', + '🇻🇬', + '🇻🇮', + '🇻🇳', + '🇻🇺', + '🇼🇫', + '🇼🇸', + '🇽🇰', + '🇾🇪', + '🇾🇹', + '🇿🇦', + '🇿🇲', + '🇿🇼', + '🏴󠁧󠁢󠁥󠁮󠁧󠁿', + '🏴󠁧󠁢󠁳󠁣󠁴󠁿', + '🏴󠁧󠁢󠁷󠁬󠁳󠁿', +]; diff --git a/lib/chat/draft_model.dart b/lib/chat/draft_model.dart new file mode 100644 index 0000000..31c4ac9 --- /dev/null +++ b/lib/chat/draft_model.dart @@ -0,0 +1,350 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:matrix/matrix.dart'; +import 'package:mime/mime.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; +import 'package:video_compress/video_compress.dart'; + +import '../app_config.dart'; +import '../common/logging.dart'; +import 'local_image_service.dart'; + +class DraftModel extends SafeChangeNotifier { + DraftModel({ + required Client client, + required LocalImageService localImageService, + }) : _client = client, + _localImageService = localImageService; + + final Client _client; + final LocalImageService _localImageService; + + bool _sending = false; + bool get sending => _sending; + void _setSending(bool value) { + if (value == _sending) return; + _sending = value; + notifyListeners(); + } + + Event? _replyEvent; + Event? get replyEvent => _replyEvent; + void setReplyEvent(Event? event) { + _replyEvent = event; + notifyListeners(); + } + + final Map _editEvents = {}; + Event? getEditEvent(String roomId) => _editEvents[roomId]; + void setEditEvent({required String roomId, Event? event}) { + _editEvents[roomId] = event; + notifyListeners(); + } + + Future send({ + required Room room, + required Function(String error) onFail, + }) async { + _setSending(true); + + try { + room.setTyping(false); + } on Exception catch (e, s) { + onFail(e.toString()); + printMessageInDebugMode(e, s); + } + + final matrixFiles = List.from(getFilesDraft(room.id)); + if (matrixFiles.isNotEmpty) { + for (var matrixFile in matrixFiles) { + removeFileFromDraft(roomId: room.id, file: matrixFile); + final xFile = _matrixFilesToXFile[matrixFile]; + String? eventId; + try { + eventId = await room.sendFileEvent( + matrixFile, + thumbnail: matrixFile.mimeType.startsWith('video') && xFile != null + ? await getVideoThumbnail(xFile) + : null, + ); + if (eventId != null) { + _matrixFilesToXFile.remove(matrixFile); + } + } on Exception catch (e, s) { + onFail(e.toString()); + printMessageInDebugMode(e, s); + } + } + } + + final draft = getDraft(room.id); + if (draft?.isNotEmpty ?? false) { + try { + await room.sendTextEvent( + draft!.trim(), + inReplyTo: replyEvent, + editEventId: _editEvents[room.id]?.eventId, + ); + } on Exception catch (e, s) { + onFail(e.toString()); + printMessageInDebugMode(e, s); + } + } + + _sending = false; + _replyEvent = null; + _editEvents[room.id] = null; + removeDraft(room.id); + + notifyListeners(); + } + + final Map _drafts = {}; + int get draftLength => _drafts.length; + Map get drafts => _drafts; + String? getDraft(String roomId) => _drafts[roomId]; + void setDraft({ + required String roomId, + required String draft, + required bool notify, + }) { + _drafts[roomId] = draft; + if (notify) { + notifyListeners(); + } + } + + void removeDraft(String roomId) { + final oldDraft = _drafts[roomId]; + if (oldDraft == null) return; + _drafts.remove(roomId); + notifyListeners(); + } + + final Map> _filesDrafts = {}; + int get filesDraftLength => _filesDrafts.length; + Map> get filesDrafts => _filesDrafts; + List getFilesDraft(String roomId) => _filesDrafts[roomId] ?? []; + void addFileToDraft({ + required String roomId, + required MatrixFile file, + }) { + final files = _filesDrafts[roomId] ?? []; + if (!files.contains(file)) { + files.add(file); + _localImageService.put(id: file.name, data: file.bytes); + _filesDrafts.update( + roomId, + (value) => files, + ifAbsent: () => files, + ); + } + notifyListeners(); + } + + void removeFileFromDraft({required String roomId, required MatrixFile file}) { + final files = _filesDrafts[roomId] ?? []; + if (files.contains(file)) { + files.remove(file); + _filesDrafts.update( + roomId, + (value) => files, + ); + notifyListeners(); + } + if (getFilesDraft(roomId).isEmpty) { + setAttaching(false); + } + } + + bool _attaching = false; + bool get attaching => _attaching; + void setAttaching(bool value) { + if (value == _attaching) return; + _attaching = value; + notifyListeners(); + } + + final Map _matrixFilesToXFile = {}; + Future addAttachment(String roomId) async { + setAttaching(true); + + List? xFiles; + if (Platform.isLinux) { + xFiles = await openFiles(); + } else { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.any, + ); + xFiles = result?.files + .map( + (f) => XFile( + f.path!, + mimeType: lookupMimeType(f.path!), + ), + ) + .toList(); + } + + if (xFiles?.isEmpty ?? true) { + setAttaching(false); + return; + } + + for (var xFile in xFiles!) { + final mime = xFile.mimeType; + final bytes = await xFile.readAsBytes(); + MatrixFile matrixFile; + if (mime?.startsWith('image') == true) { + matrixFile = await MatrixImageFile.shrink( + bytes: bytes, + name: xFile.name, + mimeType: mime, + maxDimension: 1000, + nativeImplementations: _client.nativeImplementations, + ); + } else if (mime?.startsWith('video') == true) { + matrixFile = + MatrixVideoFile(bytes: bytes, name: xFile.name, mimeType: mime); + _matrixFilesToXFile.update( + matrixFile, + (v) => xFile, + ifAbsent: () => xFile, + ); + } else { + matrixFile = MatrixFile.fromMimeType( + bytes: bytes, + name: xFile.name, + mimeType: mime, + ); + } + + addFileToDraft( + roomId: roomId, + file: matrixFile, + ); + } + + setAttaching(false); + } + + static const int max = 1200; + static const int quality = 40; + + Future resizeVideo(XFile xFile) async { + MediaInfo? mediaInfo; + try { + if (isMobilePlatform) { + // will throw an error e.g. on Android SDK < 18 + mediaInfo = await VideoCompress.compressVideo(xFile.path); + } + } catch (e, s) { + Logs().w('Error while compressing video', e, s); + } + return MatrixVideoFile( + bytes: + (await mediaInfo?.file?.readAsBytes()) ?? await xFile.readAsBytes(), + name: xFile.name, + mimeType: xFile.mimeType, + width: mediaInfo?.width, + height: mediaInfo?.height, + duration: mediaInfo?.duration?.round(), + ); + } + + // final Map _fileMap = {}; + + Future getVideoThumbnail(XFile xFile) async { + if (!isMobilePlatform) return null; + + try { + final bytes = await VideoCompress.getByteThumbnail(xFile.path); + if (bytes == null) return null; + return MatrixImageFile( + bytes: bytes, + name: xFile.name, + ); + } catch (e, s) { + Logs().w('Error while compressing video', e, s); + } + return null; + } + + bool _attachingAvatar = false; + bool get attachingAvatar => _attachingAvatar; + void setAttachingAvatar(bool value) { + if (value == _attachingAvatar) return; + _attachingAvatar = value; + + notifyListeners(); + } + + MatrixFile? _avatarDraftFile; + MatrixFile? get avatarDraftFile => _avatarDraftFile; + void resetAvatar() { + _avatarDraftFile = null; + notifyListeners(); + } + + Future setRoomAvatar({ + required Room? room, + required Function(String error) onFail, + required Function() onWrongFileFormat, + }) async { + setAttachingAvatar(true); + + try { + XFile? xFile; + if (Platform.isLinux) { + xFile = await openFile(); + } else { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.any, + ); + xFile = result?.files + .map( + (f) => XFile( + f.path!, + mimeType: lookupMimeType(f.path!), + ), + ) + .toList() + .firstOrNull; + } + + if (xFile == null) { + setAttachingAvatar(false); + return; + } + + final mime = xFile.mimeType; + final bytes = await xFile.readAsBytes(); + + if (mime?.startsWith('image') == true) { + _avatarDraftFile = await MatrixImageFile.shrink( + bytes: bytes, + name: xFile.name, + mimeType: mime, + maxDimension: 1000, + nativeImplementations: _client.nativeImplementations, + ); + } else { + onWrongFileFormat(); + } + + if (room != null) { + await room.setAvatar(_avatarDraftFile); + _avatarDraftFile = null; + } + } on Exception catch (e, s) { + onFail(e.toString()); + printMessageInDebugMode(e, s); + } + + setAttachingAvatar(false); + } +} diff --git a/lib/chat/event_x.dart b/lib/chat/event_x.dart new file mode 100644 index 0000000..ac04e5e --- /dev/null +++ b/lib/chat/event_x.dart @@ -0,0 +1,20 @@ +import 'package:matrix/matrix.dart'; + +extension EventX on Event { + bool get isImage => messageType == MessageTypes.Image; + + bool get showAsBadge => { + EventTypes.RoomAvatar, + EventTypes.RoomAliases, + EventTypes.RoomTopic, + EventTypes.RoomCreate, + EventTypes.RoomPowerLevels, + EventTypes.RoomJoinRules, + EventTypes.HistoryVisibility, + EventTypes.RoomName, + EventTypes.RoomMember, + EventTypes.Unknown, + EventTypes.GuestAccess, + EventTypes.Encryption, + }.contains(type); +} diff --git a/lib/chat/local_image_model.dart b/lib/chat/local_image_model.dart new file mode 100644 index 0000000..3f4aabf --- /dev/null +++ b/lib/chat/local_image_model.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import 'local_image_service.dart'; + +class LocalImageModel extends SafeChangeNotifier { + LocalImageModel({ + required LocalImageService service, + }) : _service = service; + + final LocalImageService _service; + StreamSubscription? _propertiesChangedSub; + + int get storeLength => _service.storeLength; + Uint8List? get(String? id) => _service.get(id); + + Future downloadImage({ + required Event event, + bool getThumbnail = true, + }) async => + _service.downloadImage( + event: event, + getThumbnail: getThumbnail, + ); + + Future downloadMxcCached({ + required Uri uri, + num? width, + num? height, + ThumbnailMethod? thumbnailMethod, + bool isThumbnail = false, + bool? animated, + }) async => + _service.downloadMxcCached( + uri: uri, + width: width, + height: height, + thumbnailMethod: thumbnailMethod, + isThumbnail: isThumbnail, + animated: animated, + ); + + void init() => _propertiesChangedSub ??= + _service.propertiesChanged.listen((_) => notifyListeners()); + + @override + Future dispose() async { + await _propertiesChangedSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/chat/local_image_service.dart b/lib/chat/local_image_service.dart new file mode 100644 index 0000000..1b81ace --- /dev/null +++ b/lib/chat/local_image_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:matrix/matrix.dart'; + +class LocalImageService { + LocalImageService({required Client client}) : _client = client; + + final Client _client; + + final _propertiesChangedController = StreamController.broadcast(); + Stream get propertiesChanged => _propertiesChangedController.stream; + + final _store = {}; + int get storeLength => _store.length; + + Future downloadImage({ + required Event event, + required bool getThumbnail, + }) async { + final bytes = + (await event.downloadAndDecryptAttachment(getThumbnail: getThumbnail)) + .bytes; + + final cover = put( + id: event.eventId, + data: bytes, + ); + if (cover != null) { + _propertiesChangedController.add(true); + } + return cover; + } + + Future downloadMxcCached({ + required Uri uri, + num? width, + num? height, + ThumbnailMethod? thumbnailMethod, + bool isThumbnail = false, + bool? animated, + }) async { + final bytes = await _client.downloadMxcCached( + uri, + width: width, + height: height, + thumbnailMethod: thumbnailMethod, + isThumbnail: isThumbnail, + animated: animated, + httpHeaders: httpHeaders, + ); + + final cover = put( + id: uri.toString(), + data: bytes, + ); + if (cover != null) { + _propertiesChangedController.add(true); + } + return cover; + } + + Map get httpHeaders => { + 'authorization': 'Bearer ${_client.accessToken}', + }; + + Uint8List? put({required String id, Uint8List? data}) => + _store.containsKey(id) + ? _store.update(id, (value) => data) + : _store.putIfAbsent(id, () => data); + + Uint8List? get(String? id) => id == null ? null : _store[id]; + + Future dispose() async => _propertiesChangedController.close(); +} + +extension _ClientDownloadContentExtension on Client { + Future downloadMxcCached( + Uri mxc, { + num? width, + num? height, + bool isThumbnail = false, + bool? animated, + ThumbnailMethod? thumbnailMethod, + required Map httpHeaders, + }) async { + final cacheKey = isThumbnail + ? await mxc.getThumbnailUri( + this, + width: width, + height: height, + animated: animated, + method: thumbnailMethod!, + ) + : mxc; + + final cachedData = await database?.getFile(cacheKey); + if (cachedData != null) return cachedData; + + final httpUri = isThumbnail + ? await mxc.getThumbnailUri( + this, + width: width, + height: height, + animated: animated, + method: thumbnailMethod, + ) + : await mxc.getDownloadUri(this); + + final response = await httpClient.get( + httpUri, + headers: accessToken == null ? null : httpHeaders, + ); + if (response.statusCode != 200) { + throw Exception(); + } + final remoteData = response.bodyBytes; + + await database?.storeFile(cacheKey, remoteData, 0); + + return remoteData; + } +} diff --git a/lib/chat/matrix_file_x.dart b/lib/chat/matrix_file_x.dart new file mode 100644 index 0000000..91f1a0b --- /dev/null +++ b/lib/chat/matrix_file_x.dart @@ -0,0 +1,7 @@ +import 'package:matrix/matrix.dart'; + +extension MatrixFileX on MatrixFile { + bool get isImage => mimeType.startsWith('image'); + bool get isVideo => mimeType.startsWith('video'); + bool get isAudio => mimeType.startsWith('audio'); +} diff --git a/lib/chat/remote_image_model.dart b/lib/chat/remote_image_model.dart new file mode 100644 index 0000000..8b219db --- /dev/null +++ b/lib/chat/remote_image_model.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import 'remote_image_service.dart'; + +class RemoteImageModel extends SafeChangeNotifier { + RemoteImageModel({ + required RemoteImageService service, + }) : _onlineArtService = service; + + final RemoteImageService _onlineArtService; + Map get httpHeaders => _onlineArtService.httpHeaders; + StreamSubscription? _propertiesChangedSub; + Uri? getAvatarUri(Uri? key) => _onlineArtService.get(key); + Map get store => _onlineArtService.store; + Future fetchAvatarUri({ + required Uri uri, + num? width, + num? height, + ThumbnailMethod? method, + bool? animated = false, + }) async => + _onlineArtService.fetchAvatarUri( + uri: uri, + animated: animated, + height: height, + width: width, + method: method, + ); + + void init() { + _propertiesChangedSub ??= + _onlineArtService.propertiesChanged.listen((_) => notifyListeners()); + } + + @override + Future dispose() async { + await _propertiesChangedSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/chat/remote_image_service.dart b/lib/chat/remote_image_service.dart new file mode 100644 index 0000000..a17dd10 --- /dev/null +++ b/lib/chat/remote_image_service.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; + +class RemoteImageService { + RemoteImageService({required Client client}) : _client = client; + final Client _client; + final _propertiesChangedController = StreamController.broadcast(); + Stream get propertiesChanged => _propertiesChangedController.stream; + + Map get httpHeaders => { + 'authorization': 'Bearer ${_client.accessToken}', + }; + + Future fetchAvatarUri({ + required Uri uri, + num? width, + num? height, + ThumbnailMethod? method = ThumbnailMethod.scale, + bool? animated = false, + }) async { + final albumArtUrl = put( + key: uri, + url: await uri.getThumbnailUri( + _client, + animated: animated, + height: height, + width: width, + method: method, + ), + ); + _propertiesChangedController.add(true); + + return albumArtUrl; + } + + final _store = {}; + Map get store => _store; + + Uri? put({required Uri key, Uri? url}) { + return _store.containsKey(key) + ? _store.update(key, (value) => url) + : _store.putIfAbsent(key, () => url); + } + + Uri? get(Uri? key) => key == null ? null : _store[key]; + + Future dispose() async => _propertiesChangedController.close(); +} diff --git a/lib/chat/room_x.dart b/lib/chat/room_x.dart new file mode 100644 index 0000000..f855b69 --- /dev/null +++ b/lib/chat/room_x.dart @@ -0,0 +1,36 @@ +import 'package:matrix/matrix.dart'; + +extension RoomX on Room { + List getSeenByUsers(List events, {String? eventId}) { + if (events.isEmpty) return []; + eventId ??= events.first.eventId; + + final lastReceipts = {}; + for (final e in events) { + lastReceipts.addAll( + e.receipts.map((r) => r.user).where( + (u) => u.id != client.userID && u.id != events.first.senderId, + ), + ); + if (e.eventId == eventId) { + break; + } + } + + return lastReceipts.toList(); + } + + bool get canEdit => + ownPowerLevel == 100 || + canKick || + canBan || + canChangeGuestAccess || + canChangeHistoryVisibility || + canChangeJoinRules || + canInvite || + canRedact || + canChangeStateEvent(EventTypes.RoomName) || + canChangeStateEvent(EventTypes.RoomAliases) || + canChangeStateEvent(EventTypes.RoomJoinRules) || + canChangeStateEvent(EventTypes.RoomPowerLevels); +} diff --git a/lib/chat/rooms_filter.dart b/lib/chat/rooms_filter.dart new file mode 100644 index 0000000..8b07a73 --- /dev/null +++ b/lib/chat/rooms_filter.dart @@ -0,0 +1,27 @@ +import 'package:matrix/matrix.dart'; + +import '../l10n/l10n.dart'; + +enum RoomsFilter { + spaces, + unread, + directChat, + privateGroups, + publicRooms; + + bool Function(Room) get filter => switch (this) { + directChat => (r) => r.isDirectChat, + unread => (r) => r.isUnreadOrInvited, + privateGroups => (r) => !r.isDirectChat && r.encrypted, + publicRooms => (r) => !r.isDirectChat && !r.encrypted && !r.isSpace, + spaces => (r) => r.isSpace, + }; + + String localize(AppLocalizations l10n) => switch (this) { + directChat => l10n.directChats, + unread => l10n.unread, + privateGroups => '${l10n.encrypted} ${l10n.groups}', + publicRooms => l10n.publicRooms, + spaces => l10n.spaces, + }; +} diff --git a/lib/chat/search_model.dart b/lib/chat/search_model.dart new file mode 100644 index 0000000..5a15f98 --- /dev/null +++ b/lib/chat/search_model.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; +import 'package:safe_change_notifier/safe_change_notifier.dart'; + +import '../common/logging.dart'; + +class SearchModel extends SafeChangeNotifier { + SearchModel({required Client client}) : _client = client; + + final Client _client; + + @override + Future dispose() async { + _debounce?.cancel(); + super.dispose(); + } + + Future lookupProfile(String userId) async => + _client.getProfileFromUserId(userId); + + bool _searchActive = false; + bool get searchActive => _searchActive; + void toggleSearch({bool? value}) { + _searchActive = value ?? !_searchActive; + notifyListeners(); + } + + SearchType _searchType = SearchType.profiles; + SearchType get searchType => _searchType; + void setSearchType(SearchType value) { + if (_searchType == value) return; + _searchType = value; + notifyListeners(); + } + + bool _spaceSearchVisible = true; + bool get spaceSearchVisible => _spaceSearchVisible; + void setSpaceSearchVisible({required bool value}) { + _spaceSearchVisible = value; + notifyListeners(); + } + + List? _spaceSearch = []; + List? get spaceSearch => _spaceSearch; + void resetSpaceSearch() { + _spaceSearch = []; + notifyListeners(); + } + + Future searchSpaces( + Room room, { + required Function(String error) onFail, + }) async { + setSpaceSearchVisible(value: true); + String? nextBatch; + _spaceSearch = null; + notifyListeners(); + final hierarchy = await _client.getSpaceHierarchy( + room.id, + suggestedOnly: false, + maxDepth: 2, + from: nextBatch, + ); + nextBatch = hierarchy.nextBatch; + _spaceSearch = hierarchy.rooms + .where((c) => room.client.getRoomById(c.roomId) == null) + .toList(); + + notifyListeners(); + } + + Timer? _debounce; + + Future?> findUserProfiles( + String searchQuery, { + required Function() onFail, + }) async { + SearchUserDirectoryResponse? searchUserDirectoryResponse; + if (_debounce?.isActive ?? false) _debounce?.cancel(); + _debounce = Timer(const Duration(seconds: 1), () async { + try { + searchUserDirectoryResponse = + await _client.searchUserDirectory(searchQuery); + } on Exception catch (e, s) { + onFail(); + printMessageInDebugMode(e, s); + } + }); + + return searchUserDirectoryResponse?.results; + } + + Future> findPublicRoomChunks( + String searchQuery, { + required Function(String error) onFail, + }) async { + QueryPublicRoomsResponse? roomSearchResult; + try { + roomSearchResult = await _client.queryPublicRooms( + filter: PublicRoomQueryFilter( + genericSearchTerm: searchQuery, + ), + limit: 200, + ); + + if (searchQuery.isValidMatrixId && + roomSearchResult.chunk.any( + (room) => + room.canonicalAlias + ?.toLowerCase() + .contains(searchQuery.toLowerCase()) == + true, + ) == + false) { + final response = await _client.getRoomIdByAlias(searchQuery); + final roomId = response.roomId; + if (roomId != null) { + roomSearchResult.chunk.add( + PublicRoomsChunk( + name: searchQuery, + guestCanJoin: false, + numJoinedMembers: 0, + roomId: roomId, + worldReadable: true, + canonicalAlias: searchQuery, + ), + ); + } + } + } catch (e) { + onFail(e.toString()); + } + + return (roomSearchResult?.chunk ?? []) + .where( + (r) => + searchType == SearchType.spaces ? r.roomType == 'm.space' : true, + ) + .toList(); + } +} + +enum SearchType { + profiles, + rooms, + spaces; +} diff --git a/lib/chat/view/chat_avatar.dart b/lib/chat/view/chat_avatar.dart new file mode 100644 index 0000000..e83f57f --- /dev/null +++ b/lib/chat/view/chat_avatar.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/build_context_x.dart'; +import '../../common/view/safe_network_image.dart'; +import '../../common/view/theme.dart'; +import '../remote_image_model.dart'; + +class ChatAvatar extends StatefulWidget with WatchItStatefulWidgetMixin { + const ChatAvatar({ + super.key, + this.dimension = 38, + this.fallBackIcon, + this.fallBackIconSize, + this.avatarUri, + this.fallBackColor, + this.onTap, + this.borderRadius, + this.fit = BoxFit.cover, + }); + + final double dimension; + final Uri? avatarUri; + final IconData? fallBackIcon; + final double? fallBackIconSize; + final Color? fallBackColor; + final void Function()? onTap; + final BorderRadius? borderRadius; + final BoxFit fit; + + @override + State createState() => _ChatAvatarState(); +} + +class _ChatAvatarState extends State { + Future? _futureUri; + + @override + void initState() { + super.initState(); + final uri = widget.avatarUri; + + if (di().getAvatarUri(uri) == null && uri != null) { + _futureUri = di().fetchAvatarUri( + uri: uri, + width: widget.dimension, + height: widget.dimension, + ); + } + } + + @override + Widget build(BuildContext context) { + final borderRadius = + widget.borderRadius ?? BorderRadius.circular(widget.dimension / 2); + final uri = watchPropertyValue( + (RemoteImageModel m) => m.getAvatarUri(widget.avatarUri), + ); + + final sizedBox = SizedBox.square( + dimension: widget.dimension, + child: ClipRRect( + borderRadius: borderRadius, + child: ColoredBox( + color: widget.fallBackColor ?? + getMonochromeBg( + theme: context.theme, + factor: 6, + darkFactor: 20, + ), + child: uri != null || _futureUri == null + ? SafeNetworkImage( + httpHeaders: di().httpHeaders, + url: uri.toString(), + fit: widget.fit, + fallBackIcon: Icon( + widget.fallBackIcon ?? YaruIcons.user, + size: widget.fallBackIconSize, + ), + ) + : FutureBuilder( + future: _futureUri, + builder: (context, snapshot) { + if (kIsWeb) { + return (snapshot.data == null + ? Icon( + widget.fallBackIcon ?? YaruIcons.user, + size: widget.fallBackIconSize, + ) + : Image.network(snapshot.data!.toString())); + } else { + return SafeNetworkImage( + fit: widget.fit, + httpHeaders: di().httpHeaders, + url: snapshot.data?.toString(), + fallBackIcon: Icon( + widget.fallBackIcon ?? YaruIcons.user, + size: widget.fallBackIconSize, + ), + ); + } + }, + ), + ), + ), + ); + + if (widget.onTap == null) { + return sizedBox; + } + + return InkWell( + borderRadius: borderRadius, + onTap: widget.onTap, + child: sizedBox, + ); + } +} diff --git a/lib/chat/view/chat_image.dart b/lib/chat/view/chat_image.dart new file mode 100644 index 0000000..83d9dc7 --- /dev/null +++ b/lib/chat/view/chat_image.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/common_widgets.dart'; +import '../../common/view/image_shimmer.dart'; +import '../../common/view/ui_constants.dart'; +import '../chat_download_model.dart'; +import '../chat_model.dart'; +import '../local_image_model.dart'; +import 'events/chat_event_status_icon.dart'; +import 'events/chat_message_attachment_indicator.dart'; +import 'events/chat_message_menu.dart'; +import 'events/chat_message_reactions.dart'; + +class ChatImage extends StatelessWidget with WatchItMixin { + const ChatImage({ + super.key, + required this.event, + required this.timeline, + this.dimension, + this.height, + this.fit, + this.width = imageWidth, + this.onlyThumbnail = true, + this.onTap, + }); + + final Event event; + final Timeline timeline; + final double? dimension; + final double? height; + final double width; + final BoxFit? fit; + final bool onlyThumbnail; + final VoidCallback? onTap; + + static const double imageWidth = 370.0; + static const double imageHeight = 270.0; + + @override + Widget build(BuildContext context) { + final theHeight = dimension ?? height ?? imageHeight; + final theWidth = dimension ?? width; + final theFit = fit ?? BoxFit.cover; + final isUserMessage = di().isUserEvent(event); + final maybeImage = + watchPropertyValue((LocalImageModel m) => m.get(event.eventId)); + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: kBigPadding, + horizontal: kMediumPadding, + ), + child: Align( + alignment: isUserMessage ? Alignment.centerRight : Alignment.centerLeft, + child: ClipRRect( + borderRadius: const BorderRadius.all(kBigBubbleRadius), + child: Stack( + children: [ + ChatMessageMenu( + event: event, + child: SizedBox( + height: theHeight, + width: theWidth, + child: maybeImage != null + ? Image.memory( + maybeImage, + fit: theFit, + height: theHeight, + width: theWidth, + ) + : ChatImageFuture( + event: event, + width: theWidth, + height: theHeight, + fit: theFit, + ), + ), + ), + Positioned( + left: kSmallPadding, + bottom: kSmallPadding, + child: ChatMessageReactions( + key: ValueKey('${event.eventId}reactions'), + event: event, + timeline: timeline, + ), + ), + Positioned( + top: kSmallPadding, + right: 10 * kSmallPadding, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.5), + shape: const CircleBorder(), + ), + onPressed: () => di().safeFile(event), + icon: ChatMessageAttachmentIndicator( + event: event, + iconSize: 22, + color: Colors.white, + ), + ), + ), + if (isUserMessage) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: kSmallPadding), + margin: const EdgeInsets.all(kMediumPadding), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(kBigPadding), + color: Colors.black.withValues(alpha: 0.5), + ), + child: ChatEventStatusIcon( + padding: const EdgeInsets.all(kTinyPadding), + event: event, + foregroundColor: Colors.white, + ), + ), + ) + else + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: kSmallPadding), + margin: const EdgeInsets.all(kMediumPadding), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(kBigPadding), + color: Colors.black.withValues(alpha: 0.5), + ), + child: ChatEventStatusIcon( + padding: const EdgeInsets.all(kTinyPadding), + event: event, + foregroundColor: Colors.white, + ), + ), + ), + Positioned( + top: kSmallPadding, + right: kSmallPadding, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.5), + shape: const CircleBorder(), + ), + onPressed: onTap, + icon: const Icon( + YaruIcons.fullscreen, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class ChatImageFuture extends StatefulWidget { + const ChatImageFuture({ + super.key, + required this.event, + this.height, + required this.width, + this.fit, + this.fromCache = true, + }); + + final Event event; + final double? height; + final double width; + final BoxFit? fit; + final bool fromCache; + + @override + State createState() => _ChatImageFutureState(); +} + +class _ChatImageFutureState extends State { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = widget.fromCache + ? di().downloadImage(event: widget.event) + : widget.event.downloadAndDecryptAttachment(getThumbnail: true); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = widget.fromCache + ? snapshot.data + : (snapshot.data as MatrixFile).bytes; + return Image.memory( + data!, + fit: widget.fit, + height: widget.height, + width: widget.width, + ); + } + + return widget.fromCache + ? const ImageShimmer() + : const Center( + child: Progress(), + ); + }, + ); +} diff --git a/lib/chat/view/chat_invitation_dialog.dart b/lib/chat/view/chat_invitation_dialog.dart new file mode 100644 index 0000000..4ca1a0e --- /dev/null +++ b/lib/chat/view/chat_invitation_dialog.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +class ChatInvitationDialog extends StatelessWidget with WatchItMixin { + const ChatInvitationDialog({super.key, required this.room}); + + final Room room; + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text('Invitation: ${room.name}'), + actions: [], + ); +} diff --git a/lib/chat/view/chat_master/chat_all_unread_rooms_badge.dart b/lib/chat/view/chat_master/chat_all_unread_rooms_badge.dart new file mode 100644 index 0000000..924e2fc --- /dev/null +++ b/lib/chat/view/chat_master/chat_all_unread_rooms_badge.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../chat_model.dart'; + +// TODO: move this to a registerStreamHandler -> send to operating system +class ChatAllUnreadRoomsBadge extends StatelessWidget { + const ChatAllUnreadRoomsBadge({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final count = di().rooms.where((r) => r.isUnread).length; + + return Badge( + label: Text( + count.toString(), + style: TextStyle( + color: context.theme.colorScheme.onPrimary, + fontSize: 12, + ), + ), + isLabelVisible: count != 0, + ); + } +} diff --git a/lib/chat/view/chat_master/chat_master_detail_page.dart b/lib/chat/view/chat_master/chat_master_detail_page.dart new file mode 100644 index 0000000..f766e81 --- /dev/null +++ b/lib/chat/view/chat_master/chat_master_detail_page.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../bootstrap/bootstrap_model.dart'; +import '../../bootstrap/view/bootstrap_page.dart'; +import '../../chat_model.dart'; +import '../chat_room/chat_room_page.dart'; +import '../no_selected_room_page.dart'; +import 'chat_master_panel.dart'; + +final GlobalKey masterScaffoldKey = GlobalKey(); + +class ChatMasterDetailPage extends StatefulWidget + with WatchItStatefulWidgetMixin { + const ChatMasterDetailPage({super.key}); + + @override + State createState() => _ChatMasterDetailPageState(); +} + +class _ChatMasterDetailPageState extends State { + @override + void initState() { + super.initState(); + di().isBootrapNeeded().then( + (isNeeded) { + if (isNeeded) { + di().startBootstrap(wipe: false).then((value) { + if (mounted) { + showDialog( + context: context, + builder: (context) => const SizedBox( + height: 500, + width: 400, + child: BootstrapPage(), + ), + ); + } + }); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + final selectedRoom = watchPropertyValue((ChatModel m) => m.selectedRoom); + final isArchivedRoom = + watchPropertyValue((ChatModel m) => m.selectedRoom?.isArchived == true); + final processingJoinOrLeave = + watchPropertyValue((ChatModel m) => m.processingJoinOrLeave); + final loadingArchive = + watchPropertyValue((ChatModel m) => m.loadingArchive); + + return Scaffold( + key: masterScaffoldKey, + drawer: + !Platform.isMacOS ? const Drawer(child: ChatMasterSidePanel()) : null, + endDrawer: + Platform.isMacOS ? const Drawer(child: ChatMasterSidePanel()) : null, + body: Row( + children: [ + if (context.showSideBar) + const SizedBox(width: kSideBarWith, child: ChatMasterSidePanel()), + if (context.showSideBar) + const VerticalDivider( + width: 0, + thickness: 0, + ), + if (processingJoinOrLeave || loadingArchive) + const Expanded( + child: Center( + child: Progress(), + ), + ) + else if (selectedRoom == null) + const Expanded(child: NoSelectedRoomPage()) + else + Expanded( + child: ChatRoomPage( + key: ValueKey( + '${selectedRoom.id} $isArchivedRoom', + ), + room: selectedRoom, + ), + ), + ], + ), + ); + } +} diff --git a/lib/chat/view/chat_master/chat_master_list_filter_bar.dart b/lib/chat/view/chat_master/chat_master_list_filter_bar.dart new file mode 100644 index 0000000..ea57bc9 --- /dev/null +++ b/lib/chat/view/chat_master/chat_master_list_filter_bar.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/sliver_sticky_panel.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../../rooms_filter.dart'; + +class ChatMasterListFilterBar extends StatelessWidget with WatchItMixin { + const ChatMasterListFilterBar({super.key}); + + @override + Widget build(BuildContext context) { + final roomsFilter = watchPropertyValue((ChatModel m) => m.roomsFilter); + return SliverStickyPanel( + child: YaruChoiceChipBar( + showCheckMarks: false, + shrinkWrap: false, + style: YaruChoiceChipBarStyle.stack, + labels: RoomsFilter.values + .map((e) => Text(e.localize(context.l10n))) + .toList(), + isSelected: RoomsFilter.values.map((e) => e == roomsFilter).toList(), + onSelected: (i) => + di().setRoomsFilter(RoomsFilter.values[i]), + ), + ); + } +} diff --git a/lib/chat/view/chat_master/chat_master_panel.dart b/lib/chat/view/chat_master/chat_master_panel.dart new file mode 100644 index 0000000..3cf6b76 --- /dev/null +++ b/lib/chat/view/chat_master/chat_master_panel.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/confirm.dart'; +import '../../../common/view/sliver_sticky_panel.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../authentication/authentication_model.dart'; +import '../../authentication/chat_login_page.dart'; +import '../../chat_model.dart'; +import '../../rooms_filter.dart'; +import '../../search_model.dart'; +import '../chat_avatar.dart'; +import '../chat_room/chat_create_or_edit_room_dialog.dart'; +import '../search_auto_complete.dart'; +import 'chat_master_list_filter_bar.dart'; +import 'chat_room_master_tile.dart'; +import 'chat_space_filter.dart'; + +class ChatMasterSidePanel extends StatelessWidget with WatchItMixin { + const ChatMasterSidePanel({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final chatModel = di(); + + final searchModel = di(); + + final searchActive = watchPropertyValue((SearchModel m) => m.searchActive); + final searchType = watchPropertyValue((SearchModel m) => m.searchType); + final archiveActive = watchPropertyValue((ChatModel m) => m.archiveActive); + final loadingArchive = + watchPropertyValue((ChatModel m) => m.loadingArchive); + + final suffix = IconButton( + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + ), + ), + onPressed: () => searchModel.setSearchType( + switch (searchType) { + SearchType.profiles => SearchType.rooms, + SearchType.rooms => SearchType.spaces, + SearchType.spaces => SearchType.profiles, + }, + ), + icon: switch (searchType) { + SearchType.profiles => const Icon(YaruIcons.user), + SearchType.rooms => const Icon(YaruIcons.users), + SearchType.spaces => const Icon(YaruIcons.globe) + }, + ); + + final panelBg = getPanelBg(context.theme); + return Material( + color: panelBg, + child: Column( + children: [ + YaruWindowTitleBar( + heroTag: '', + title: Text(archiveActive ? l10n.archive : l10n.chats), + border: BorderSide.none, + style: YaruTitleBarStyle.undecorated, + backgroundColor: + getMonochromeBg(theme: context.theme, darkFactor: 3), + actions: [ + Flexible( + child: IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => const ChatCreateOrEditRoomDialog(), + ), + icon: const Icon( + YaruIcons.plus, + ), + ), + ), + if (!archiveActive) + Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 5), + child: IconButton( + isSelected: searchActive, + onPressed: searchModel.toggleSearch, + icon: const Icon(YaruIcons.search), + ), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 5), + child: IconButton( + selectedIcon: const Icon(YaruIcons.bookmark_filled), + isSelected: archiveActive, + onPressed: chatModel.toggleArchive, + icon: const Icon(YaruIcons.bookmark), + ), + ), + ), + ], + ), + if (searchActive && !archiveActive) + Padding( + padding: const EdgeInsets.only( + left: 15, + right: 15, + bottom: kMediumPadding, + ), + child: switch (searchType) { + SearchType.profiles => SearchAutoComplete( + suffix: suffix, + ), + _ => RoomsAutoComplete( + suffix: suffix, + ) + }, + ), + if (loadingArchive) + const Expanded( + child: Center(child: Progress()), + ) + else + const Expanded( + child: _RoomList(), + ), + YaruMasterTile( + title: const Text('Logout'), + leading: const Icon(YaruIcons.log_out), + onTap: () => showDialog( + context: context, + builder: (context) => ConfirmationDialog( + title: Text(l10n.logout), + content: Text(l10n.areYouSureYouWantToLogout), + onConfirm: () { + chatModel.setSelectedRoom(null); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => const ChatLoginPage(), + ), + (route) => false, + ); + di().logout( + onFail: (e) => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + ), + ), + ); + }, + ), + ), + ), + const SizedBox( + height: kMediumPadding, + ), + ], + ), + ); + } +} + +class _RoomList extends StatelessWidget with WatchItMixin { + const _RoomList(); + + @override + Widget build(BuildContext context) { + final selectedRoom = watchPropertyValue((ChatModel m) => m.selectedRoom); + final archiveActive = watchPropertyValue((ChatModel m) => m.archiveActive); + final roomsFilter = watchPropertyValue((ChatModel m) => m.roomsFilter); + watchPropertyValue((ChatModel m) => m.filteredRooms.length); + final activeSpace = watchPropertyValue((ChatModel m) => m.activeSpace); + final spaceSearch = watchPropertyValue((SearchModel m) => m.spaceSearch); + final spaceSearchVisible = + watchPropertyValue((SearchModel m) => m.spaceSearchVisible); + + return StreamBuilder( + stream: di().syncStream, + builder: (context, snapshot) { + final filteredRooms = di().filteredRooms; + + return CustomScrollView( + slivers: [ + const ChatMasterListFilterBar(), + if (roomsFilter == RoomsFilter.spaces && !archiveActive) + const ChatSpaceFilter(), + if (roomsFilter == RoomsFilter.spaces && + activeSpace != null && + !archiveActive) + SliverStickyPanel( + toolbarHeight: 50, + child: Padding( + padding: const EdgeInsets.only( + bottom: kMediumPadding, + top: kMediumPadding, + ), + child: Row( + spacing: kMediumPadding, + children: [ + Expanded( + child: ElevatedButton( + onPressed: spaceSearch == null + ? null + : () => di().searchSpaces( + activeSpace, + onFail: (e) => + showSnackBar(context, content: Text(e)), + ), + child: Text(context.l10n.discover), + ), + ), + SizedBox.square( + dimension: 38, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + ), + onPressed: () => di().leaveSelectedRoom( + room: activeSpace, + onFail: (e) => + showSnackBar(context, content: Text(e)), + ), + child: const Icon( + YaruIcons.log_out, + ), + ), + ), + ], + ), + ), + ), + if (spaceSearchVisible && + !archiveActive && + roomsFilter == RoomsFilter.spaces) + const _SpaceSearchList(), + if (roomsFilter == RoomsFilter.spaces && !archiveActive) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only( + top: kSmallPadding, + bottom: kMediumPadding, + ), + child: Divider(), + ), + ), + SliverList.builder( + itemCount: filteredRooms.length, + itemBuilder: (context, i) { + final room = filteredRooms[i]; + + return ChatRoomMasterTile( + key: ValueKey(room.id), + selected: selectedRoom?.id == room.id, + room: room, + ); + }, + ), + ], + ); + }, + ); + } +} + +class _SpaceSearchList extends StatelessWidget with WatchItMixin { + const _SpaceSearchList(); + + @override + Widget build(BuildContext context) { + final spaceSearch = watchPropertyValue((SearchModel m) => m.spaceSearch); + final spaceSearchL = + watchPropertyValue((SearchModel m) => m.spaceSearch?.length ?? 0); + if (spaceSearch == null) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(kBigPadding), + child: Progress(), + ), + ); + } + + if (spaceSearch.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return SliverList.builder( + itemCount: spaceSearchL, + itemBuilder: (context, index) { + final chunk = spaceSearch.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: YaruMasterTile( + key: ValueKey(chunk.roomId), + leading: ChatAvatar( + avatarUri: chunk.avatarUrl, + ), + title: Text(chunk.name ?? chunk.roomId), + subtitle: Tooltip( + margin: const EdgeInsets.all(kBigPadding), + message: chunk.topic ?? ' ', + child: Text(chunk.canonicalAlias ?? chunk.topic.toString()), + ), + onTap: () { + di().setSpaceSearchVisible(value: false); + di().joinAndSelectRoomByChunk( + chunk, + onFail: (error) => showSnackBar(context, content: Text(error)), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/chat/view/chat_master/chat_room_master_tile.dart b/lib/chat/view/chat_master/chat_room_master_tile.dart new file mode 100644 index 0000000..af06fd6 --- /dev/null +++ b/lib/chat/view/chat_master/chat_room_master_tile.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/scaffold_state_x.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../../draft_model.dart'; +import '../chat_avatar.dart'; +import '../chat_invitation_dialog.dart'; +import '../chat_room/chat_room_master_tile_subtitle.dart'; +import 'chat_master_detail_page.dart'; + +class ChatRoomMasterTile extends StatelessWidget with WatchItMixin { + const ChatRoomMasterTile({ + super.key, + required this.room, + required this.selected, + }); + + final Room room; + final bool selected; + + @override + Widget build(BuildContext context) { + final chatModel = di(); + + final selectedRoom = watchPropertyValue((ChatModel m) => m.selectedRoom); + final processingJoinOrLeave = + watchPropertyValue((ChatModel m) => m.processingJoinOrLeave); + final loadingArchive = + watchPropertyValue((ChatModel m) => m.loadingArchive); + + return Opacity( + opacity: processingJoinOrLeave || loadingArchive ? 0.5 : 1, + child: Padding( + padding: const EdgeInsets.only(bottom: 5), + child: YaruMasterTile( + selected: selectedRoom?.id != null && selectedRoom?.id == room.id, + leading: ChatAvatar( + key: ValueKey(room.avatar ?? room.id), + avatarUri: room.avatar, + fallBackIcon: room.membership != Membership.invite + ? room.isDirectChat + ? YaruIcons.user + : YaruIcons.users + : YaruIcons.mail_unread, + ), + title: Text(room.getLocalizedDisplayname()), + trailing: room.isArchived + ? null + : Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + spacing: kSmallPadding, + children: [ + if (room.isFavourite) + Flexible(child: ChatRoomPinIcon(room: room)), + if (room.notificationCount > 0) + Flexible( + child: Badge( + largeSize: 11, + smallSize: 11, + label: Text( + room.notificationCount.toString(), + ), + ), + ), + ], + ), + subtitle: ChatRoomMasterTileSubTitle(room: room), + onTap: processingJoinOrLeave || loadingArchive + ? null + : () async { + if (room.isArchived) { + chatModel.setSelectedRoom(room); + } else { + if (room.membership == Membership.invite) { + showDialog( + context: context, + builder: (context) => ChatInvitationDialog(room: room), + ); + } else { + await chatModel.joinRoom( + room, + onFail: (e) => showSnackBar( + context, + content: Text(e), + ), + ); + } + } + di().setAttaching(false); + + masterScaffoldKey.currentState?.hideDrawer(); + }, + ), + ), + ); + } +} + +class ChatRoomPinIcon extends StatelessWidget with WatchItMixin { + const ChatRoomPinIcon({ + super.key, + required this.room, + }); + + final Room room; + + @override + Widget build(BuildContext context) { + watchStream((ChatModel m) => m.getJoinedRoomUpdate(room.id)).data; + + return Icon( + YaruIcons.pin, + color: room.isFavourite ? context.colorScheme.primary : null, + size: 20, + ); + } +} diff --git a/lib/chat/view/chat_master/chat_space_filter.dart b/lib/chat/view/chat_master/chat_space_filter.dart new file mode 100644 index 0000000..ed84008 --- /dev/null +++ b/lib/chat/view/chat_master/chat_space_filter.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/sliver_sticky_panel.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../../search_model.dart'; +import '../chat_avatar.dart'; + +class ChatSpaceFilter extends StatelessWidget with WatchItMixin { + const ChatSpaceFilter({super.key}); + + @override + Widget build(BuildContext context) { + final chatModel = di(); + final activeSpaceId = watchPropertyValue((ChatModel m) => m.activeSpace); + + final spaces = watchStream( + (ChatModel m) => m.spacesStream, + initialValue: chatModel.notArchivedSpaces, + ).data ?? + []; + + return SliverStickyPanel( + toolbarHeight: 56, + child: SizedBox( + height: 46, + child: ListView.separated( + separatorBuilder: (context, index) => const SizedBox( + width: kSmallPadding, + ), + scrollDirection: Axis.horizontal, + itemCount: spaces.length, + itemBuilder: (context, index) { + final space = spaces.elementAt(index); + return Tooltip( + message: space.name, + child: YaruSelectableContainer( + onTap: () { + chatModel.setActiveSpace(space); + di().resetSpaceSearch(); + }, + padding: EdgeInsets.zero, + selected: activeSpaceId == space, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(4), + child: ChatAvatar( + avatarUri: space.avatar, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_profile_dialog.dart b/lib/chat/view/chat_profile_dialog.dart new file mode 100644 index 0000000..a26717c --- /dev/null +++ b/lib/chat/view/chat_profile_dialog.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/build_context_x.dart'; +import '../../common/view/common_widgets.dart'; +import '../../common/view/snackbars.dart'; +import '../../common/view/ui_constants.dart'; +import '../chat_model.dart'; +import '../search_model.dart'; +import 'chat_avatar.dart'; + +class ChatProfileDialog extends StatelessWidget { + const ChatProfileDialog({super.key, required this.userId}); + + final String userId; + + @override + Widget build(BuildContext context) { + return SimpleDialog( + children: [ + ChatProfile(userId: userId), + ], + ); + } +} + +class ChatProfile extends StatefulWidget { + const ChatProfile({ + super.key, + required this.userId, + this.showButton = true, + }); + + final String userId; + final bool showButton; + + @override + State createState() => _ChatProfileState(); +} + +class _ChatProfileState extends State { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = di().lookupProfile(widget.userId); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) => SizedBox( + height: 350, + width: 200, + child: Center( + child: Padding( + padding: const EdgeInsets.all(kBigPadding), + child: snapshot.hasData + ? Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Center( + child: ChatAvatar( + fallBackIconSize: 80, + dimension: 120, + avatarUri: snapshot.data?.avatarUrl, + ), + ), + Column( + spacing: kSmallPadding, + children: [ + Text( + snapshot.data!.userId, + textAlign: TextAlign.center, + style: context.theme.textTheme.bodyMedium, + ), + Text( + snapshot.data!.displayName ?? '', + textAlign: TextAlign.center, + style: context.theme.textTheme.bodyLarge, + ), + ], + ), + if (widget.showButton && + snapshot.data!.userId != di().myUserId) + Flexible( + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(YaruIcons.chat_bubble), + onPressed: () { + Navigator.of(context).pop(); + di().joinDirectChat( + snapshot.data!.userId, + onFail: (error) => showSnackBar( + context, + content: Text(error), + ), + ); + }, + label: const Text('Start direct chat'), + ), + ), + ), + ], + ) + : const Center(child: Progress()), + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/chat_create_or_edit_room_dialog.dart b/lib/chat/view/chat_room/chat_create_or_edit_room_dialog.dart new file mode 100644 index 0000000..4a410cd --- /dev/null +++ b/lib/chat/view/chat_room/chat_create_or_edit_room_dialog.dart @@ -0,0 +1,430 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart' hide Visibility; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/space.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../../draft_model.dart'; +import '../chat_avatar.dart'; +import '../search_auto_complete.dart'; +import 'chat_room_create_or_edit_avatar.dart'; +import 'chat_room_users_list.dart'; + +const _maxWidth = 400.0; + +class ChatCreateOrEditRoomDialog extends StatefulWidget + with WatchItStatefulWidgetMixin { + const ChatCreateOrEditRoomDialog({ + super.key, + this.room, + this.groupName, + this.joinedUsers, + this.initialState, + this.visibility, + this.historyVisibility, + this.groupCall, + this.powerLevelContentOverride, + this.federated, + this.encrypted, + }); + + final Room? room; + final String? groupName; + final List? joinedUsers; + final List? initialState; + final Visibility? visibility; + final HistoryVisibility? historyVisibility; + final bool? groupCall; + final Map? powerLevelContentOverride; + final bool? federated; + final bool? encrypted; + + @override + State createState() => + _ChatCreateOrEditRoomDialogState(); +} + +class _ChatCreateOrEditRoomDialogState + extends State { + late Visibility _visibility; + Set _profiles = {}; + String? _groupName; + String? _topic; + late bool _enableEncryption; + late bool _federated; + late bool _groupCall; + + late final bool _existingGroup; + + late final TextEditingController _groupNameController; + late final TextEditingController _groupTopicController; + + @override + void initState() { + super.initState(); + _groupName = widget.room?.name ?? widget.groupName; + _topic = widget.room?.topic; + _existingGroup = widget.room != null; + + _enableEncryption = widget.room?.encrypted ?? widget.encrypted ?? false; + _federated = widget.room?.isFederated ?? widget.federated ?? true; + _groupCall = widget.room?.hasActiveGroupCall ?? widget.groupCall ?? false; + _groupNameController = TextEditingController(text: _groupName); + _groupTopicController = TextEditingController(text: _topic); + _visibility = (widget.room?.joinRules == JoinRules.public + ? Visibility.public + : Visibility.private); + _profiles = (widget.room?.getParticipants() ?? widget.joinedUsers) + ?.where( + (e) { + final id = e.id.split(':').firstOrNull?.replaceAll('@', ''); + + return id?.isNotEmpty == true; + }, + ) + .map( + (e) => Profile( + userId: e.id.split(':').firstOrNull!.replaceAll('@', ''), + avatarUrl: e.avatarUrl, + ), + ) + .toSet() ?? + {}; + } + + @override + void dispose() { + _groupNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final mediaQueryWidth = context.mediaQuerySize.width; + final twoPaneMode = mediaQueryWidth > 1200; + + final usedWidth = mediaQueryWidth > 520.0 ? _maxWidth : 280.0; + final avatarDraftFile = + watchPropertyValue((DraftModel m) => m.avatarDraftFile); + + final profileListView = ListView.builder( + padding: EdgeInsets.symmetric( + horizontal: kMediumPadding, + vertical: twoPaneMode ? 0 : kBigPadding, + ), + itemCount: _profiles.length, + itemBuilder: (context, index) { + final t = _profiles.elementAt(index); + return ListTile( + shape: const RoundedRectangleBorder(), + contentPadding: EdgeInsets.zero, + key: ValueKey(t.userId), + leading: ChatAvatar(avatarUri: t.avatarUrl), + title: Text( + t.displayName ?? t.userId, + maxLines: 1, + ), + subtitle: Text( + t.userId, + maxLines: 1, + ), + trailing: t.userId == di().myUserId + ? null + : IconButton( + onPressed: () => setState(() { + _profiles.remove(t); + if (_existingGroup) widget.room!.kick(t.userId); + }), + icon: const Icon( + YaruIcons.trash, + ), + ), + ); + }, + ); + + final userSearchField = Padding( + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + child: SearchAutoComplete( + width: usedWidth - 2 * kMediumPadding, + suffix: const Icon(YaruIcons.user), + onProfileSelected: (p) { + if (_existingGroup) { + widget.room!.invite(p.userId); + } else { + setState(() => _profiles.add(p)); + } + }, + ), + ); + + final leftColumn = Column( + mainAxisSize: MainAxisSize.min, + spacing: kBigPadding, + children: [ + ChatRoomCreateOrEditAvatar( + avatarDraftBytes: avatarDraftFile?.bytes, + room: widget.room, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + child: TextField( + autofocus: true, + enabled: widget.room == null || + widget.room?.canChangeStateEvent(EventTypes.RoomName) == true, + controller: _groupNameController, + onChanged: (v) => setState(() { + _groupName = v; + }), + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(12), + label: Text(l10n.groupName), + suffixIcon: (_existingGroup && + widget.room!.canChangeStateEvent(EventTypes.RoomName)) + ? IconButton( + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + ), + ), + onPressed: _groupName != widget.room!.name + ? () => widget.room!.setName(_groupName!) + : null, + icon: const Icon(YaruIcons.save), + ) + : null, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + child: TextField( + enabled: widget.room == null || + widget.room?.canChangeStateEvent(EventTypes.RoomTopic) == true, + controller: _groupTopicController, + onChanged: (v) => setState(() { + _topic = v; + }), + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(12), + label: Text(l10n.chatDescription), + suffixIcon: (_existingGroup && + widget.room!.canChangeStateEvent(EventTypes.RoomTopic)) + ? IconButton( + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + ), + ), + onPressed: _topic != widget.room!.topic + ? () => widget.room!.setDescription(_topic!) + : null, + icon: const Icon(YaruIcons.save), + ) + : null, + ), + ), + ), + YaruTile( + leading: _enableEncryption + ? const Icon(YaruIcons.shield_filled) + : const Icon(YaruIcons.shield), + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + trailing: CommonSwitch( + value: _enableEncryption, + onChanged: _enableEncryption || + widget.encrypted == true || + widget.room?.encrypted == true || + widget.room?.canChangeStateEvent(EventTypes.Encryption) == + false + ? null + : (v) { + if (widget.room != null) { + widget.room!.enableEncryption(); + } + + setState(() => _enableEncryption = v); + }, + ), + title: Text(l10n.encrypted), + subtitle: widget.encrypted == true || widget.room?.encrypted == true + ? null + : (Text(l10n.enableEncryptionWarning)), + ), + YaruTile( + leading: _visibility == Visibility.private + ? const Icon(YaruIcons.private_mask_filled) + : const Icon(YaruIcons.private_mask), + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + title: _visibility == Visibility.private + ? Text(l10n.guestsCanJoin) + : Text(l10n.anyoneCanJoin), + trailing: CommonSwitch( + value: _visibility == Visibility.private, + onChanged: + widget.room?.canChangeStateEvent(EventTypes.RoomJoinRules) == + false + ? null + : (v) { + if (v) { + widget.room?.setJoinRules(JoinRules.public); + } else { + widget.room?.setJoinRules(JoinRules.private); + } + setState( + () => _visibility = + v ? Visibility.private : Visibility.public, + ); + }, + ), + ), + if (!twoPaneMode) userSearchField, + if (!twoPaneMode) + Expanded( + child: _existingGroup + ? ChatRoomUsersList( + room: widget.room!, + sliver: false, + showChatIcon: false, + ) + : profileListView, + ), + ], + ); + + return AlertDialog( + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar( + border: BorderSide.none, + backgroundColor: Colors.transparent, + title: Text( + _existingGroup ? '${l10n.edit} ${l10n.group}' : l10n.createGroup, + ), + ), + actionsAlignment: MainAxisAlignment.start, + actionsOverflowAlignment: OverflowBarAlignment.center, + actionsPadding: const EdgeInsets.all(kBigPadding + kMediumPadding), + contentPadding: const EdgeInsets.all(kBigPadding), + content: SizedBox( + height: 2 * _maxWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: usedWidth, + child: leftColumn, + ), + if (twoPaneMode) + const Padding( + padding: EdgeInsets.all(kBigPadding), + child: VerticalDivider( + width: 0.1, + thickness: 0.5, + ), + ), + if (twoPaneMode) + SizedBox( + width: usedWidth, + child: Column( + spacing: kMediumPadding, + children: [ + userSearchField, + Expanded( + child: _profiles.isEmpty + ? const Center( + child: Icon( + YaruIcons.users, + size: 100, + ), + ) + : _existingGroup + ? ChatRoomUsersList( + room: widget.room!, + sliver: false, + showChatIcon: false, + ) + : profileListView, + ), + ], + ), + ), + ], + ), + ), + scrollable: false, + actions: _existingGroup + ? null + : [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: space( + expand: !twoPaneMode, + widthGap: kMediumPadding, + children: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: _groupName == null || + _groupName!.trim().isEmpty + ? null + : () { + di().createRoom( + avatarFile: avatarDraftFile, + enableEncryption: _enableEncryption, + preset: CreateRoomPreset.publicChat, + invite: _profiles.map((p) => p.userId).toList(), + groupName: _groupName, + visibility: _visibility, + onFail: (error) => + showSnackBar(context, content: Text(error)), + onSuccess: () { + di().resetAvatar(); + if (context.mounted && + Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + groupCall: _groupCall, + federated: _federated, + ); + }, + child: Text( + l10n.ok, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_create_or_edit_avatar.dart b/lib/chat/view/chat_room/chat_room_create_or_edit_avatar.dart new file mode 100644 index 0000000..4c7d0fe --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_create_or_edit_avatar.dart @@ -0,0 +1,113 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app_config.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../../draft_model.dart'; +import '../chat_avatar.dart'; + +class ChatRoomCreateOrEditAvatar extends StatelessWidget with WatchItMixin { + const ChatRoomCreateOrEditAvatar({ + super.key, + required this.room, + this.avatarDraftBytes, + }); + + final Room? room; + final Uint8List? avatarDraftBytes; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final ava = watchStream( + (ChatModel m) => m.getJoinedRoomAvatarStream(room), + initialValue: room?.avatar, + ).data; + final foreGroundColor = yaru ? Colors.white : null; + + final attachingAvatar = + watchPropertyValue((DraftModel m) => m.attachingAvatar); + + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(kSmallPadding), + child: room != null + ? ChatAvatar( + avatarUri: ava, + dimension: 80, + fallBackIconSize: 40, + ) + : SizedBox.square( + dimension: 80, + child: ClipRRect( + borderRadius: BorderRadius.circular(80 / 2), + child: ColoredBox( + color: getMonochromeBg( + theme: context.theme, + factor: 6, + darkFactor: 15, + ), + child: avatarDraftBytes == null + ? const Icon( + YaruIcons.user, + size: 40, + ) + : Image.memory( + avatarDraftBytes!, + fit: BoxFit.cover, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: IconButton.filled( + style: IconButton.styleFrom( + shape: const CircleBorder(), + disabledBackgroundColor: context.colorScheme.surface, + disabledForegroundColor: foreGroundColor, + ), + onPressed: attachingAvatar || + room != null && + room?.canChangeStateEvent(EventTypes.RoomAvatar) != true + ? null + : () => di().setRoomAvatar( + room: room, + onFail: (error) => + showSnackBar(context, content: Text(error)), + onWrongFileFormat: () => showSnackBar( + context, + content: Text(l10n.notAnImage), + ), + ), + icon: attachingAvatar + ? SizedBox.square( + dimension: 15, + child: Progress( + strokeWidth: 2, + color: foreGroundColor, + ), + ) + : Icon( + YaruIcons.pen, + color: foreGroundColor, + ), + ), + ), + ], + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_default_background.dart b/lib/chat/view/chat_room/chat_room_default_background.dart new file mode 100644 index 0000000..4134058 --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_default_background.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:mesh/mesh.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app_config.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/theme.dart'; + +class ChatRoomDefaultBackground extends StatelessWidget { + const ChatRoomDefaultBackground({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + final bg = colorScheme.surface; + final accent = colorScheme.primary; + final remix = yaru + ? remixColor( + accent, + palette: YaruVariant.accents.map((e) => e.color).toList(), + ) + : colorScheme.primaryContainer; + return Opacity( + opacity: 0.3, + child: OMeshGradient( + mesh: _createMesh( + fallbackColor: bg, + backgroundColor: bg, + colors: [ + bg.withValues(alpha: 0.01), + bg.withValues(alpha: 0.01), + bg.withValues(alpha: 0.01), + accent.withValues(alpha: 0.11), + accent.withValues(alpha: 0.31), + accent.withValues(alpha: 0.11), + remix.withValues(alpha: 0.02), + remix.withValues(alpha: 0.41), + bg.withValues(alpha: 0.71), + bg.withValues(alpha: 0.91), + bg.withValues(alpha: 0.80), + bg, + ], + ), + ), + ); + } +} + +OMeshRect _createMesh({ + required Color? fallbackColor, + required Color? backgroundColor, + required List colors, +}) { + return OMeshRect( + width: 3, + height: 4, + fallbackColor: fallbackColor, + backgroundColor: backgroundColor, + vertices: [ + (0.01, -0.18).v.bezier( + east: (0.08, -0.01).v, + south: (-0.06, 0.13).v, + ), + (0.6, -0.15).v.bezier( + east: (0.95, -0.1).v, + south: (0.67, -0.14).v, + west: (0.38, -0.2).v, + ), + (0.67, -0.27).v.bezier( + west: (0.54, -0.21).v, + ), // Row 1 + (-0.03, 0.44).v.bezier( + north: (-0.03, 0.29).v, + ), + (0.6, 0.39).v.bezier( + north: (0.62, 0.39).v, + east: (1.01, 0.41).v, + west: (0.37, 0.37).v, + ), + (1.07, 0.07).v.bezier( + west: (0.96, 0.23).v, + ), // Row 2 + (-0.03, 0.54).v.bezier( + east: (0.12, 0.51).v, + south: (-0.17, 0.58).v, + ), + (0.47, 0.52).v.bezier( + east: (0.68, 0.53).v, + south: (0.48, 0.54).v, + west: (0.36, 0.51).v, + ), + (1.1, 0.6).v.bezier( + north: (1.13, 0.53).v, + south: (1.04, 0.59).v, + west: (0.97, 0.59).v, + ), // Row 3 + (-0.0, 1.11).v.bezier( + north: (-0.28, 0.79).v, + east: (0.18, 1.06).v, + ), + (0.53, 1.08).v.bezier( + north: (0.56, 0.66).v, + east: (0.74, 1.11).v, + west: (0.41, 1.07).v, + ), + (1.11, 1.11).v.bezier( + north: (1.05, 0.61).v, + west: (1.04, 1.09).v, + ), // Row 4 + ], + colors: colors, + ); +} diff --git a/lib/chat/view/chat_room/chat_room_info_drawer.dart b/lib/chat/view/chat_room/chat_room_info_drawer.dart new file mode 100644 index 0000000..d44681c --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_info_drawer.dart @@ -0,0 +1,192 @@ +import 'package:animated_emoji/animated_emoji.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/confirm.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../../room_x.dart'; +import '../chat_avatar.dart'; +import 'chat_create_or_edit_room_dialog.dart'; +import 'chat_room_info_drawer_topic.dart'; +import 'chat_room_users_list.dart'; +import 'titlebar/chat_room_join_or_leave_button.dart'; + +class ChatRoomInfoDrawer extends StatelessWidget with WatchItMixin { + const ChatRoomInfoDrawer({super.key, required this.room}); + + final Room room; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = context.theme; + final textTheme = theme.textTheme; + + final avatar = watchStream( + (ChatModel m) => m.getJoinedRoomAvatarStream(room), + initialValue: room.avatar, + preserveState: false, + ).data; + + return Drawer( + child: SizedBox( + width: kSideBarWith, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (!room.isDirectChat) + AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: kMediumPadding, + children: [ + ChatAvatar( + avatarUri: room.avatar, + fallBackIcon: YaruIcons.users, + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(room.getLocalizedDisplayname()), + if (room.canonicalAlias.isNotEmpty) + Text( + room.canonicalAlias, + style: textTheme.labelSmall, + ), + Text( + room.isArchived + ? l10n.archive + : '(${room.summary.mJoinedMemberCount ?? 0} ${l10n.users})', + style: textTheme.labelSmall, + ), + ], + ), + ), + ], + ), + titleTextStyle: + textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + automaticallyImplyLeading: false, + actions: [ + if (!room.isArchived && room.canEdit) + Flexible( + key: ValueKey(room.canEdit), + child: Padding( + padding: const EdgeInsets.only(right: kSmallPadding), + child: IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => + ChatCreateOrEditRoomDialog(room: room), + ), + icon: const Icon(YaruIcons.pen), + ), + ), + ) + else + Container(), + ], + toolbarHeight: 90, + elevation: 0, + shape: const RoundedRectangleBorder(side: BorderSide.none), + ), + if (room.isDirectChat) + Padding( + padding: const EdgeInsets.only(top: 2 * kBigPadding), + child: Column( + spacing: kBigPadding, + mainAxisSize: MainAxisSize.min, + children: [ + ChatAvatar( + avatarUri: avatar, + dimension: 150, + fallBackIconSize: 80, + ), + SizedBox( + width: 200, + child: Text( + room.getLocalizedDisplayname(), + textAlign: TextAlign.center, + style: textTheme.bodyLarge, + ), + ), + ], + ), + ) + else + Expanded( + child: CustomScrollView( + slivers: room.isArchived + ? [ + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: AnimatedEmoji( + AnimatedEmojis.fallenLeaf, + size: 100, + ), + ), + ), + ] + : [ + ChatRoomInfoDrawerTopic(room: room), + ChatRoomUsersList(room: room), + ], + ), + ), + if (room.isArchived) + Container( + padding: const EdgeInsets.only( + left: kBigPadding, + right: kBigPadding, + top: kBigPadding, + ), + width: double.infinity, + child: OutlinedButton.icon( + label: Text('${l10n.delete} '), + style: room.isArchived + ? OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + backgroundColor: room.isArchived + ? theme.colorScheme.error.withValues(alpha: 0.03) + : null, + ) + : null, + onPressed: () => showDialog( + context: context, + builder: (context) => ConfirmationDialog( + onConfirm: () async => di().leaveSelectedRoom( + onFail: (error) => + showSnackBar(context, content: Text(error)), + forget: true, + ), + title: Text(l10n.delete), + content: Text( + room.getLocalizedDisplayname(), + textAlign: TextAlign.center, + ), + ), + ), + icon: Icon( + YaruIcons.trash, + color: room.isArchived ? theme.colorScheme.error : null, + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: ChatRoomJoinOrLeaveButton(room: room), + ), + ], + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart b/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart new file mode 100644 index 0000000..6d2c91b --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_info_drawer_topic.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; + +class ChatRoomInfoDrawerTopic extends StatelessWidget with WatchItMixin { + const ChatRoomInfoDrawerTopic({ + super.key, + required this.room, + }); + + final Room room; + + @override + Widget build(BuildContext context) { + final updatingTimeline = + watchPropertyValue((ChatModel m) => m.updatingTimeline); + + return SliverToBoxAdapter( + child: Center( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: updatingTimeline ? 0 : 1, + child: Padding( + padding: const EdgeInsets.only( + bottom: kMediumPadding, + ), + child: ListTile( + dense: true, + title: Html( + data: room.topic, + style: { + 'body': Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + textAlign: TextAlign.center, + fontSize: FontSize(12), + ), + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart b/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart new file mode 100644 index 0000000..6d1e9b1 --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_master_tile_subtitle.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; + +class ChatRoomMasterTileSubTitle extends StatelessWidget with WatchItMixin { + const ChatRoomMasterTileSubTitle({super.key, required this.room}); + + final Room room; + + @override + Widget build(BuildContext context) { + final typingUsers = watchStream( + (ChatModel m) => m.getTypingUsersStream(room), + initialValue: room.typingUsers, + ).data ?? + []; + + final lastEvent = watchStream( + (ChatModel m) => m.getLastEventStream(room), + initialValue: room.lastEvent, + ).data; + + return typingUsers.isEmpty + ? _LastEvent( + key: ObjectKey(lastEvent), + lastEvent: lastEvent ?? room.lastEvent, + ) + : Text( + typingUsers.length > 1 + ? context.l10n.numUsersTyping(typingUsers.length) + : context.l10n + .userIsTyping(typingUsers.first.displayName ?? ''), + style: context.textTheme.bodyMedium + ?.copyWith(color: context.colorScheme.primary), + maxLines: 1, + ); + } +} + +class _LastEvent extends StatefulWidget with WatchItStatefulWidgetMixin { + const _LastEvent({ + required this.lastEvent, + super.key, + }); + + final Event? lastEvent; + + @override + State<_LastEvent> createState() => _LastEventState(); +} + +class _LastEventState extends State<_LastEvent> { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = widget.lastEvent + ?.calcLocalizedBody(const MatrixDefaultLocalizations()) ?? + Future.value(widget.lastEvent?.body ?? ''); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + return Text( + snapshot.hasData ? snapshot.data! : ' ', + maxLines: 1, + ); + }, + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_page.dart b/lib/chat/view/chat_room/chat_room_page.dart new file mode 100644 index 0000000..1da4876 --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_page.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/confirm.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import 'chat_room_default_background.dart'; +import 'chat_room_info_drawer.dart'; +import 'chat_timeline_list.dart'; +import 'input/chat_input.dart'; +import 'titlebar/chat_room_title_bar.dart'; + +final GlobalKey chatRoomScaffoldKey = GlobalKey(); + +class ChatRoomPage extends StatefulWidget with WatchItStatefulWidgetMixin { + final Room room; + const ChatRoomPage({required this.room, super.key}); + + @override + State createState() => _ChatRoomPageState(); +} + +class _ChatRoomPageState extends State { + late final Future _timelineFuture; + final GlobalKey _roomListKey = + GlobalKey(); + + @override + void initState() { + super.initState(); + _timelineFuture = widget.room.getTimeline( + onUpdate: () => _roomListKey.currentState?.setState(() {}), + onNewEvent: () => _roomListKey.currentState?.setState(() {}), + onChange: (i) => _roomListKey.currentState?.setState(() {}), + onInsert: (i) => _roomListKey.currentState?.insertItem(i), + onRemove: (i) => + _roomListKey.currentState?.removeItem(i, (_, __) => const ListTile()), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final archiveActive = watchPropertyValue((ChatModel m) => m.archiveActive); + final loadingArchive = + watchPropertyValue((ChatModel m) => m.loadingArchive); + + registerStreamHandler( + select: (ChatModel m) => m.getLeftRoomStream(widget.room.id), + handler: (context, leftRoomUpdate, cancel) { + if (!archiveActive && !loadingArchive && leftRoomUpdate.hasData) { + di().leaveSelectedRoom( + onFail: (error) => showSnackBar( + context, + content: Text(error), + ), + ); + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ConfirmationDialog( + showCancel: false, + showCloseIcon: false, + title: Text(widget.room.getLocalizedDisplayname()), + content: Text(l10n.youAreNoLongerParticipatingInThisChat), + ), + ); + } + }, + ); + + return Stack( + children: [ + const ChatRoomDefaultBackground(), + Scaffold( + key: chatRoomScaffoldKey, + endDrawer: ChatRoomInfoDrawer(room: widget.room), + backgroundColor: Colors.transparent, + appBar: ChatRoomTitleBar(room: widget.room), + bottomNavigationBar: widget.room.isArchived + ? null + : ChatInput( + key: ValueKey('${widget.room.id}input'), + room: widget.room, + ), + body: FutureBuilder( + key: ValueKey(widget.room.id), + future: _timelineFuture, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: Progress(), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: kMediumPadding), + child: ChatTimelineList( + timeline: snapshot.data!, + room: widget.room, + listKey: _roomListKey, + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/chat/view/chat_room/chat_room_users_list.dart b/lib/chat/view/chat_room/chat_room_users_list.dart new file mode 100644 index 0000000..f3cf5f0 --- /dev/null +++ b/lib/chat/view/chat_room/chat_room_users_list.dart @@ -0,0 +1,141 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../chat_avatar.dart'; + +class ChatRoomUsersList extends StatelessWidget with WatchItMixin { + const ChatRoomUsersList({ + super.key, + required this.room, + this.sliver = true, + this.showChatIcon = true, + }); + + final Room room; + final bool sliver; + final bool showChatIcon; + + @override + Widget build(BuildContext context) { + final membershipFilter = [Membership.join]; + + watchStream( + (ChatModel m) => m.getUsersStreamOfJoinedRoom( + room, + membershipFilter: membershipFilter, + ), + initialValue: room.getParticipants(membershipFilter), + preserveState: false, + ); + + final users = watchFuture( + (users) => users, + target: room.requestParticipants(membershipFilter), + initialValue: room.getParticipants(membershipFilter), + preserveState: false, + ).data?.sorted( + (a, b) => b.powerLevel.compareTo(a.powerLevel), + ); + + if (users == null || users.isEmpty) { + if (!sliver) { + return const Center( + child: Progress(), + ); + } + + return const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Progress(), + ), + ); + } + + _UserTile itemBuilder(BuildContext context, int index) { + final user = users.elementAt(index); + return _UserTile( + key: ValueKey('invited${user.id}'), + user: user, + trailing: !showChatIcon && room.canKick + ? IconButton( + onPressed: () => room.kick(user.id), + icon: Icon( + YaruIcons.trash, + color: context.colorScheme.error, + ), + ) + : null, + ); + } + + if (sliver) { + return SliverList.builder( + itemCount: users.length, + itemBuilder: itemBuilder, + ); + } + + return ListView.builder( + itemCount: users.length, + itemBuilder: itemBuilder, + ); + } +} + +class _UserTile extends StatelessWidget { + const _UserTile({ + super.key, + required this.user, + this.trailing, + }); + + final User user; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final chatModel = di(); + return ListTile( + key: key, + leading: Opacity( + opacity: user.membership == Membership.invite ? 0.5 : 1, + child: ChatAvatar( + avatarUri: user.avatarUrl, + ), + ), + title: Text(user.displayName ?? user.id), + subtitle: user.membership == Membership.invite + ? Text(l10n.invited) + : Text( + user.powerLevel == 0 + ? context.l10n.participant + : context.l10n.admin, + ), + trailing: trailing ?? + (user.id == chatModel.myUserId + ? null + : IconButton( + onPressed: () => chatModel.joinDirectChat( + user.id, + onFail: (error) => showSnackBar( + context, + content: Text(error.toString()), + ), + ), + icon: const Icon( + YaruIcons.chat_bubble, + ), + )), + ); + } +} diff --git a/lib/chat/view/chat_room/chat_seen_by_indicator.dart b/lib/chat/view/chat_room/chat_seen_by_indicator.dart new file mode 100644 index 0000000..79f720c --- /dev/null +++ b/lib/chat/view/chat_room/chat_seen_by_indicator.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../../room_x.dart'; +import '../../search_model.dart'; +import '../chat_avatar.dart'; +import '../chat_profile_dialog.dart'; + +class ChatSeenByIndicator extends StatefulWidget + with WatchItStatefulWidgetMixin { + const ChatSeenByIndicator({ + super.key, + required this.room, + required this.timeline, + }); + + final Room room; + final Timeline timeline; + + static const maxAvatars = 7; + + @override + State createState() => _ChatSeenByIndicatorState(); +} + +class _ChatSeenByIndicatorState extends State { + @override + void initState() { + super.initState(); + widget.room.requestParticipants(); + } + + @override + Widget build(BuildContext context) { + final events = watchStream( + (ChatModel m) => m.getReadEventsFromSync(widget.room), + initialValue: widget.timeline.events, + ).data; + final list = {...widget.timeline.events, ...?events}.toList(); + final seenByUsers = widget.room.getSeenByUsers(list); + + return Container( + width: double.infinity, + alignment: widget.room.isDirectChat || + di().isUserEvent(list.first) && + list.first.type != EventTypes.Reaction + ? Alignment.centerRight + : Alignment.centerLeft, + child: AnimatedContainer( + padding: const EdgeInsets.symmetric( + vertical: kSmallPadding, + horizontal: kMediumPadding, + ), + height: seenByUsers.isEmpty ? 0 : 25, + duration: const Duration(milliseconds: 200), + child: Wrap( + spacing: kSmallPadding, + children: [ + ...(seenByUsers.length > ChatSeenByIndicator.maxAvatars + ? seenByUsers.sublist(0, ChatSeenByIndicator.maxAvatars) + : seenByUsers) + .map( + (user) => Tooltip( + key: ValueKey(user.id + user.avatarUrl.toString()), + message: user.displayName ?? user.id, + child: InkWell( + borderRadius: BorderRadius.circular(15), + onTap: () async { + final profile = + await di().lookupProfile(user.id); + if (context.mounted) { + showDialog( + context: context, + builder: (context) => + ChatProfileDialog(userId: profile.userId), + ); + } + }, + child: ChatAvatar( + avatarUri: user.avatarUrl, + fallBackIconSize: 10, + dimension: 15, + ), + ), + ), + ), + if (seenByUsers.length > ChatSeenByIndicator.maxAvatars) + SizedBox( + width: 15, + height: 15, + child: Material( + color: context.colorScheme.surface, + borderRadius: BorderRadius.circular(30), + child: Center( + child: Text( + '+${seenByUsers.length - ChatSeenByIndicator.maxAvatars}', + style: const TextStyle(fontSize: 9), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/chat_timeline_list.dart b/lib/chat/view/chat_room/chat_timeline_list.dart new file mode 100644 index 0000000..14d6a71 --- /dev/null +++ b/lib/chat/view/chat_room/chat_timeline_list.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../events/chat_event_column.dart'; +import 'chat_typing_indicator.dart'; +import 'titlebar/chat_room_title_bar.dart'; + +class ChatTimelineList extends StatefulWidget with WatchItStatefulWidgetMixin { + const ChatTimelineList({ + super.key, + required this.timeline, + required this.listKey, + required this.room, + }); + + final Timeline timeline; + final Room room; + final GlobalKey listKey; + + @override + State createState() => _ChatTimelineListState(); +} + +class _ChatTimelineListState extends State { + late AutoScrollController _controller; + bool _showScrollButton = false; + int retryCount = 15; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) { + di() + .requestHistory( + widget.timeline, + historyCount: 350, + ) + .then( + (value) { + if (widget.room.membership == Membership.join) { + widget.timeline.setReadMarker(); + } + }, + ); + }, + ); + _controller = AutoScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + + return Stack( + children: [ + Column( + children: [ + Expanded( + child: NotificationListener( + onNotification: (scrollEnd) { + final metrics = scrollEnd.metrics; + if (metrics.atEdge) { + final isAtBottom = metrics.pixels != 0; + if (isAtBottom) { + di().requestHistory( + widget.timeline, + historyCount: 150, + ); + } else { + setState(() => _showScrollButton = false); + } + } else { + setState(() => _showScrollButton = true); + } + return true; + }, + child: AnimatedList( + controller: _controller, + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + key: widget.listKey, + reverse: true, + initialItemCount: widget.timeline.events.length, + itemBuilder: (context, i, animation) { + final event = widget.timeline.events[i]; + + final maybePreviousEvent = + widget.timeline.events.elementAtOrNull(i + 1); + + if (i == 0 && !widget.room.isArchived) { + widget.timeline.setReadMarker(); + } + + return AutoScrollTag( + index: i, + controller: _controller, + key: ValueKey('${event.eventId}tag'), + child: FadeTransition( + opacity: animation, + child: ChatEventColumn( + key: ValueKey('${event.eventId}column'), + event: event, + maybePreviousEvent: maybePreviousEvent, + jump: _jump, + showSeenByIndicator: i == 0, + timeline: widget.timeline, + room: widget.room, + ), + ), + ); + }, + ), + ), + ), + ChatTypingIndicator(room: widget.room), + ], + ), + Positioned( + right: kBigPadding, + bottom: kBigPadding, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + bottom: _showScrollButton ? 3 * kBigPadding : 0, + ), + child: FloatingActionButton.small( + backgroundColor: getMonochromeBg(theme: theme, darkFactor: 5), + onPressed: () => showDialog( + context: context, + builder: (context) => ChatRoomSearchDialog(room: widget.room), + ), + child: Icon( + YaruIcons.search, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + if (_showScrollButton) + Positioned( + right: kBigPadding, + bottom: kBigPadding, + child: FloatingActionButton.small( + backgroundColor: getMonochromeBg( + theme: theme, + darkFactor: 5, + ), + child: Icon( + YaruIcons.go_down, + color: theme.colorScheme.onSurface, + ), + onPressed: () => _maybeScrollTo( + 0, + duration: const Duration( + milliseconds: 100, + ), + ), + ), + ), + ], + ); + } + + Future _jump(Event event) async { + int index = widget.timeline.events.indexOf(event); + while (index == -1 && retryCount >= 0) { + await di().requestHistory( + widget.timeline, + historyCount: 5, + ); + index = widget.timeline.events.indexOf(event); + retryCount--; + } + await _maybeScrollTo(index); + if (!widget.room.isArchived) { + widget.timeline.setReadMarker(eventId: event.eventId); + } + } + + Future _maybeScrollTo( + int index, { + Duration? duration, + }) async { + if (index == -1) { + return; + } + + await _controller.scrollToIndex( + index, + preferPosition: AutoScrollPosition.end, + duration: duration ?? const Duration(milliseconds: 50), + ); + retryCount = 15; + } +} diff --git a/lib/chat/view/chat_room/chat_typing_indicator.dart b/lib/chat/view/chat_room/chat_typing_indicator.dart new file mode 100644 index 0000000..fcf92cd --- /dev/null +++ b/lib/chat/view/chat_room/chat_typing_indicator.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../chat_avatar.dart'; + +class ChatTypingIndicator extends StatelessWidget with WatchItMixin { + final Room room; + + const ChatTypingIndicator({super.key, required this.room}); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + + final typingUsers = watchStream( + (ChatModel m) => m.getTypingUsersStream(room), + initialValue: room.typingUsers, + ).data ?? + []; + + return AnimatedContainer( + height: typingUsers.isEmpty ? 0 : kTypingAvatarSize + kMediumPadding, + duration: kAvatarAnimationDuration, + curve: kAvatarAnimationCurve, + alignment: Alignment.centerLeft, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + padding: const EdgeInsets.symmetric( + horizontal: kBigPadding, + vertical: kSmallPadding, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: kSmallPadding, + children: [ + ...typingUsers.map( + (e) => ChatAvatar( + key: ValueKey(e.id), + dimension: kTypingAvatarSize, + avatarUri: e.avatarUrl, + ), + ), + Material( + color: getMonochromeBg(theme: theme, factor: 3, darkFactor: 10), + borderRadius: const BorderRadius.all( + Radius.circular(30), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: kMediumPadding, + ), + child: typingUsers.isEmpty ? null : const _TypingDots(), + ), + ), + ], + ), + ); + } +} + +class _TypingDots extends StatefulWidget { + const _TypingDots(); + + @override + State<_TypingDots> createState() => __TypingDotsState(); +} + +class __TypingDotsState extends State<_TypingDots> { + int _tick = 0; + + late final Timer _timer; + + static const Duration animationDuration = Duration(milliseconds: 300); + + @override + void initState() { + super.initState(); + _timer = Timer.periodic( + animationDuration, + (_) { + if (!mounted) { + return; + } + setState(() { + _tick = (_tick + 1) % 4; + }); + }, + ); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + const size = 8.0; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 1; i <= 3; i++) + AnimatedContainer( + duration: animationDuration * 1.5, + curve: Curves.bounceIn, + width: size, + height: _tick == i ? size * 2 : size, + margin: EdgeInsets.symmetric( + horizontal: 2, + vertical: _tick == i ? 4 : 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size * 2), + color: theme.colorScheme.secondary, + ), + ), + ], + ); + } +} diff --git a/lib/chat/view/chat_room/input/chat_attachment_draft_panel.dart b/lib/chat/view/chat_room/input/chat_attachment_draft_panel.dart new file mode 100644 index 0000000..aa123e2 --- /dev/null +++ b/lib/chat/view/chat_room/input/chat_attachment_draft_panel.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../../common/view/ui_constants.dart'; +import '../../../draft_model.dart'; +import 'chat_pending_attachment.dart'; + +class ChatAttachmentDraftPanel extends StatelessWidget with WatchItMixin { + const ChatAttachmentDraftPanel({super.key, required this.roomId}); + + final String roomId; + + @override + Widget build(BuildContext context) { + final draftFiles = + watchPropertyValue((DraftModel m) => m.getFilesDraft(roomId)); + + final attaching = watchPropertyValue((DraftModel m) => m.attaching); + + final draftFilesL = watchPropertyValue( + (DraftModel m) => m.getFilesDraft(roomId).length, + ); + final sending = watchPropertyValue((DraftModel m) => m.sending); + + if (!attaching && draftFilesL == 0) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: kSmallPadding), + child: SizedBox( + height: ChatPendingAttachment.dimension, + child: Opacity( + opacity: sending ? 0.5 : 1, + child: Align( + alignment: Alignment.centerLeft, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: ChatPendingAttachment.dimension, + mainAxisExtent: ChatPendingAttachment.dimension, + ), + reverse: true, + shrinkWrap: true, + padding: const EdgeInsets.symmetric( + horizontal: kSmallPadding, + ), + scrollDirection: Axis.horizontal, + itemCount: draftFilesL, + itemBuilder: (context, index) { + final file = draftFiles.elementAt(index); + return AnimatedContainer( + duration: const Duration(seconds: 1), + child: ChatPendingAttachment( + onTap: sending + ? null + : () => di().removeFileFromDraft( + roomId: roomId, + file: file, + ), + file: file, + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/input/chat_emoji_picker.dart b/lib/chat/view/chat_room/input/chat_emoji_picker.dart new file mode 100644 index 0000000..f529412 --- /dev/null +++ b/lib/chat/view/chat_room/input/chat_emoji_picker.dart @@ -0,0 +1,111 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../../common/view/build_context_x.dart'; + +class ChatEmojiPicker extends StatelessWidget { + const ChatEmojiPicker({ + super.key, + this.onEmojiSelected, + }); + + final void Function(Category?, Emoji)? onEmojiSelected; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + final theme = context.theme; + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 300, + maxHeight: 300, + minWidth: 300, + maxWidth: 300, + ), + child: EmojiPicker( + onEmojiSelected: onEmojiSelected, + config: Config( + customSearchIcon: const Icon(YaruIcons.search), + customBackspaceIcon: const Icon(YaruIcons.edit_clear), + emojiViewConfig: const EmojiViewConfig( + verticalSpacing: 0, + horizontalSpacing: 0, + emojiSizeMax: 25, + backgroundColor: Colors.transparent, + ), + bottomActionBarConfig: BottomActionBarConfig( + backgroundColor: colorScheme.surface, + buttonIconColor: colorScheme.primary, + buttonColor: colorScheme.surface, + ), + skinToneConfig: const SkinToneConfig( + dialogBackgroundColor: Colors.transparent, + ), + categoryViewConfig: CategoryViewConfig( + initCategory: Category.SMILEYS, + indicatorColor: colorScheme.onSurface, + iconColorSelected: colorScheme.primary, + backspaceColor: theme.primaryColor, + backgroundColor: Colors.transparent, + iconColor: colorScheme.onSurface, + ), + searchViewConfig: SearchViewConfig( + backgroundColor: Colors.transparent, + buttonIconColor: colorScheme.onSurface, + ), + ), + ), + ); + } +} + +class ChatInputEmojiMenu extends StatefulWidget { + const ChatInputEmojiMenu({ + super.key, + required this.onEmojiSelected, + }); + + final void Function(Category?, Emoji) onEmojiSelected; + + @override + State createState() => _ChatInputEmojiMenuState(); +} + +class _ChatInputEmojiMenuState extends State { + MenuController? _controller; + + @override + void initState() { + super.initState(); + _controller = MenuController(); + } + + @override + Widget build(BuildContext context) { + return MenuAnchor( + alignmentOffset: const Offset(-40, 12), + controller: _controller, + menuChildren: [ + ChatEmojiPicker( + onEmojiSelected: (p0, p1) { + widget.onEmojiSelected(p0, p1); + _controller?.close(); + }, + ), + ], + child: IconButton( + onPressed: () { + if (_controller?.isOpen == true) { + _controller?.close(); + } else { + _controller?.open(); + } + setState(() {}); + }, + icon: const Icon(YaruIcons.emote_smile), + selectedIcon: const Icon(YaruIcons.emote_smile_big_filled), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/input/chat_input.dart b/lib/chat/view/chat_room/input/chat_input.dart new file mode 100644 index 0000000..bc35d41 --- /dev/null +++ b/lib/chat/view/chat_room/input/chat_input.dart @@ -0,0 +1,216 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../../common/view/common_widgets.dart'; +import '../../../../common/view/snackbars.dart'; +import '../../../../common/view/ui_constants.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../chat_model.dart'; +import '../../../draft_model.dart'; +import 'chat_attachment_draft_panel.dart'; +import 'chat_emoji_picker.dart'; + +class ChatInput extends StatefulWidget with WatchItStatefulWidgetMixin { + const ChatInput({super.key, required this.room}); + + final Room room; + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + late final TextEditingController _sendController; + late final FocusNode _sendNode; + + @override + void initState() { + super.initState(); + _sendController = + TextEditingController(text: di().getDraft(widget.room.id)); + _sendNode = FocusNode( + onKeyEvent: (node, KeyEvent evt) { + if (HardwareKeyboard.instance.isShiftPressed && + evt.logicalKey.keyLabel == 'Enter') { + if (evt is KeyDownEvent) { + _sendController.clear(); + _sendNode.requestFocus(); + di().send( + room: widget.room, + onFail: (error) => showSnackBar(context, content: Text(error)), + ); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + }, + ); + } + + @override + void dispose() { + _sendController.dispose(); + _sendNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final draftModel = di(); + final draftFiles = + watchPropertyValue((DraftModel m) => m.getFilesDraft(widget.room.id)); + final attaching = watchPropertyValue((DraftModel m) => m.attaching); + final sending = watchPropertyValue((DraftModel m) => m.sending); + + final replyEvent = watchPropertyValue((DraftModel m) => m.replyEvent); + final editEvent = + watchPropertyValue((DraftModel m) => m.getEditEvent(widget.room.id)); + + final draft = + watchPropertyValue((DraftModel m) => m.getDraft(widget.room.id)); + _sendController.text = draft ?? ''; + _sendNode.requestFocus(); + + var onPressed = sending + ? null + : () async { + _sendController.clear(); + _sendNode.requestFocus(); + await draftModel.send( + room: widget.room, + onFail: (error) => showSnackBar(context, content: Text(error)), + ); + }; + var transform = Transform.rotate( + angle: pi / 4, + child: const Padding( + padding: EdgeInsets.only(right: 2, top: 2), + child: Icon(YaruIcons.send_filled), + ), + ); + return Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (draftFiles.isNotEmpty) const Divider(height: 1), + if (draftFiles.isNotEmpty) + ChatAttachmentDraftPanel(roomId: widget.room.id), + if (replyEvent != null || editEvent != null) + Padding( + padding: const EdgeInsets.only( + left: kMediumPadding, + right: kMediumPadding, + top: kMediumPadding, + ), + child: YaruInfoBox( + trailing: IconButton( + onPressed: () => di() + ..setReplyEvent(null) + ..setEditEvent(roomId: widget.room.id, event: null) + ..setDraft( + roomId: widget.room.id, + draft: '', + notify: true, + ), + icon: const Icon( + YaruIcons.trash, + ), + ), + yaruInfoType: editEvent != null + ? YaruInfoType.warning + : YaruInfoType.information, + icon: Icon(editEvent != null ? YaruIcons.pen : YaruIcons.reply), + subtitle: Text( + (editEvent ?? replyEvent)!.plaintextBody, + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + title: Text( + '${context.l10n.reply} (${(editEvent ?? replyEvent)!.senderFromMemoryOrFallback.displayName}):', + ), + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(kMediumPadding), + child: TextField( + minLines: 1, + maxLines: 10, + focusNode: _sendNode, + controller: _sendController, + enabled: watchPropertyValue((ChatModel m) => !m.archiveActive), + autofocus: true, + onChanged: (v) { + draftModel.setDraft( + roomId: widget.room.id, + draft: v, + notify: false, + ); + widget.room.setTyping(v.isNotEmpty, timeout: 500); + }, + onSubmitted: (_) => onPressed?.call(), + decoration: InputDecoration( + hintText: context.l10n.sendAMessage, + prefixIcon: Padding( + padding: const EdgeInsets.all(kSmallPadding), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + padding: EdgeInsets.zero, + onPressed: attaching + ? null + : () => draftModel.addAttachment(widget.room.id), + icon: attaching + ? const Center( + child: SizedBox.square( + dimension: 15, + child: Progress( + strokeWidth: 1, + ), + ), + ) + : const Icon( + YaruIcons.plus, + ), + ), + ChatInputEmojiMenu( + onEmojiSelected: (cat, emo) { + _sendController.text = + _sendController.text + emo.emoji; + draftModel.setDraft( + roomId: widget.room.id, + draft: _sendController.text, + notify: true, + ); + _sendNode.requestFocus(); + }, + ), + ], + ), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + padding: EdgeInsets.zero, + icon: transform, + onPressed: onPressed, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/chat/view/chat_room/input/chat_pending_attachment.dart b/lib/chat/view/chat_room/input/chat_pending_attachment.dart new file mode 100644 index 0000000..950179a --- /dev/null +++ b/lib/chat/view/chat_room/input/chat_pending_attachment.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../../common/view/build_context_x.dart'; +import '../../../../common/view/ui_constants.dart'; +import '../../../matrix_file_x.dart'; + +class ChatPendingAttachment extends StatelessWidget { + const ChatPendingAttachment({ + super.key, + this.onTap, + required this.file, + }); + + final VoidCallback? onTap; + final MatrixFile file; + + static const dimension = 220.0; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(kSmallPadding), + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(kBigBubbleRadius), + child: file.isImage + ? Image.memory( + file.bytes, + height: dimension, + width: dimension, + fit: BoxFit.cover, + ) + : ChatPendingFile( + file: file, + height: dimension, + width: dimension, + ), + ), + if (onTap != null) + Positioned( + right: kSmallPadding, + top: kSmallPadding, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.8), + shape: const CircleBorder(), + ), + onPressed: onTap, + icon: const Icon( + YaruIcons.window_close, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } +} + +class ChatPendingFile extends StatelessWidget { + const ChatPendingFile({ + super.key, + required this.file, + this.height, + this.width, + }); + + final MatrixFile file; + final double? height, width; + + @override + Widget build(BuildContext context) => Container( + height: height, + width: width, + color: context.colorScheme.outline, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: kSmallPadding, + children: [ + Icon( + file.isVideo + ? YaruIcons.video_filled + : file.isAudio + ? YaruIcons.media_play + : YaruIcons.document_filled, + color: Colors.white, + size: 100, + ), + Padding( + padding: const EdgeInsets.all(kMediumPadding), + child: Text(file.name), + ), + ], + ), + ), + ); +} diff --git a/lib/chat/view/chat_room/titlebar/chat_room_encryption_status_button.dart b/lib/chat/view/chat_room/titlebar/chat_room_encryption_status_button.dart new file mode 100644 index 0000000..5866a05 --- /dev/null +++ b/lib/chat/view/chat_room/titlebar/chat_room_encryption_status_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; +import '../../../../common/view/build_context_x.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../chat_model.dart'; + +class ChatRoomEncryptionStatusButton extends StatelessWidget with WatchItMixin { + const ChatRoomEncryptionStatusButton({ + super.key, + required this.room, + }); + + final Room room; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colorScheme = context.colorScheme; + watchStream((ChatModel m) => m.getJoinedRoomUpdate(room.id)).data; + return IconButton( + onPressed: null, + tooltip: room.encrypted ? l10n.encrypted : l10n.encryptionNotEnabled, + icon: !room.encrypted + ? Icon(YaruIcons.shield_warning, color: colorScheme.onSurface) + : Icon(YaruIcons.shield_filled, color: colorScheme.onSurface), + ); + } +} diff --git a/lib/chat/view/chat_room/titlebar/chat_room_join_or_leave_button.dart b/lib/chat/view/chat_room/titlebar/chat_room_join_or_leave_button.dart new file mode 100644 index 0000000..a1cca37 --- /dev/null +++ b/lib/chat/view/chat_room/titlebar/chat_room_join_or_leave_button.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; +import '../../../../app_config.dart'; +import '../../../../common/view/build_context_x.dart'; +import '../../../../common/view/confirm.dart'; +import '../../../../common/view/snackbars.dart'; +import '../../../../common/view/ui_constants.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../chat_model.dart'; + +class ChatRoomJoinOrLeaveButton extends StatelessWidget { + const ChatRoomJoinOrLeaveButton({ + super.key, + required this.room, + }); + + final Room room; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final chatModel = di(); + + final joinedRoom = room.membership == Membership.join; + + final roomName = room.getLocalizedDisplayname(); + + final notReJoinable = room.isAbandonedDMRoom; + + final message = joinedRoom + ? '${l10n.leave} $roomName' + : notReJoinable + ? roomName + : '${l10n.joinRoom} $roomName'; + + return Container( + padding: const EdgeInsets.all(kBigPadding), + width: double.infinity, + child: OutlinedButton.icon( + label: Text(room.isArchived ? l10n.joinRoom : l10n.leave), + style: !room.isArchived + ? OutlinedButton.styleFrom( + foregroundColor: yaru + ? context.colorScheme.error + : context.colorScheme.primary, + ) + : null, + onPressed: notReJoinable + ? null + : () => showDialog( + context: context, + builder: (context) => ConfirmationDialog( + title: Text(message), + onConfirm: () { + void onFail(error) => + showSnackBar(context, content: Text(error)); + + if (joinedRoom) { + chatModel.leaveSelectedRoom( + onFail: onFail, + forget: room.isDirectChat, + ); + } else { + chatModel.joinRoom( + room, + onFail: onFail, + clear: true, + select: false, + ); + } + }, + ), + ), + icon: !room.isArchived + ? Icon( + YaruIcons.log_out, + color: yaru + ? context.colorScheme.error + : context.colorScheme.primary, + ) + : const Icon(YaruIcons.log_in), + ), + ); + } +} diff --git a/lib/chat/view/chat_room/titlebar/chat_room_pin_button.dart b/lib/chat/view/chat_room/titlebar/chat_room_pin_button.dart new file mode 100644 index 0000000..ab00f9b --- /dev/null +++ b/lib/chat/view/chat_room/titlebar/chat_room_pin_button.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; +import '../../../../common/view/build_context_x.dart'; +import '../../../chat_model.dart'; + +class ChatRoomPinButton extends StatelessWidget with WatchItMixin { + const ChatRoomPinButton({ + super.key, + required this.room, + }); + + final Room room; + + @override + Widget build(BuildContext context) { + watchStream((ChatModel m) => m.getJoinedRoomUpdate(room.id)).data; + + return IconButton( + onPressed: () => room.setFavourite(!room.isFavourite), + icon: Icon( + YaruIcons.pin, + color: room.isFavourite ? context.colorScheme.primary : null, + ), + ); + } +} diff --git a/lib/chat/view/chat_room/titlebar/chat_room_title_bar.dart b/lib/chat/view/chat_room/titlebar/chat_room_title_bar.dart new file mode 100644 index 0000000..2a4be91 --- /dev/null +++ b/lib/chat/view/chat_room/titlebar/chat_room_title_bar.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../../common/view/build_context_x.dart'; +import '../../../../common/view/common_widgets.dart'; +import '../../../../common/view/space.dart'; +import '../../../../common/view/ui_constants.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../chat_model.dart'; +import '../../side_bar_button.dart'; +import '../chat_room_page.dart'; +import 'chat_room_encryption_status_button.dart'; +import 'chat_room_pin_button.dart'; + +class ChatRoomTitleBar extends StatelessWidget + with WatchItMixin + implements PreferredSizeWidget { + const ChatRoomTitleBar({super.key, required this.room}); + + final Room room; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final updating = watchPropertyValue((ChatModel m) => m.updatingTimeline); + + return YaruWindowTitleBar( + heroTag: '', + border: BorderSide.none, + backgroundColor: Colors.transparent, + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: kSmallPadding, + children: [ + ChatRoomEncryptionStatusButton(room: room), + Flexible( + child: Text( + '${room.isArchived ? '(${l10n.archive}) ' : ''}${room.getLocalizedDisplayname()}', + ), + ), + ], + ), + leading: !Platform.isMacOS && !context.showSideBar + ? const SideBarButton() + : null, + actions: space( + widthGap: kSmallPadding, + children: [ + if (updating) + const Padding( + padding: EdgeInsets.only(right: kSmallPadding), + child: SizedBox.square( + dimension: 15, + child: Progress( + strokeWidth: 2, + ), + ), + ), + if (!room.isArchived) ChatRoomPinButton(room: room), + IconButton( + onPressed: () => chatRoomScaffoldKey.currentState?.openEndDrawer(), + icon: const Icon(YaruIcons.information), + ), + if (!context.showSideBar && !kIsWeb && Platform.isMacOS) + const SideBarButton(), + const SizedBox(width: kSmallPadding), + ].map((e) => Flexible(child: e)).toList(), + ), + ); + } + + @override + Size get preferredSize => const Size(0, kYaruTitleBarHeight); +} + +class ChatRoomSearchDialog extends StatefulWidget + with WatchItStatefulWidgetMixin { + const ChatRoomSearchDialog({ + super.key, + required this.room, + }); + + final Room room; + + @override + State createState() => _ChatRoomSearchDialogState(); +} + +class _ChatRoomSearchDialogState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final events = watchFuture( + (ChatModel m) => m.getEvents(widget.room), + initialValue: [], + ).data; + + final filteredEvents = events?.where( + (e) => e.body.toLowerCase().contains(_controller.text.toLowerCase()), + ); + + return SimpleDialog( + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar( + title: + Text(context.l10n.searchIn(widget.room.getLocalizedDisplayname())), + border: BorderSide.none, + backgroundColor: Colors.transparent, + ), + children: [ + Padding( + padding: const EdgeInsets.all(kBigPadding), + child: TextField( + autocorrect: true, + autofocus: true, + controller: _controller, + decoration: InputDecoration( + label: Text( + context.l10n.search, + ), + ), + ), + ), + filteredEvents == null + ? const Center( + child: Progress(), + ) + : Padding( + padding: const EdgeInsets.all(kMediumPadding), + child: SizedBox( + height: 500, + width: 500, + child: ListView.builder( + reverse: true, + itemCount: filteredEvents.length, + itemBuilder: (context, index) { + final event = filteredEvents.elementAt(index); + return ListTile( + dense: true, + title: Text(event.body), + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/chat/view/chat_start_page.dart b/lib/chat/view/chat_start_page.dart new file mode 100644 index 0000000..3038257 --- /dev/null +++ b/lib/chat/view/chat_start_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../common/view/common_widgets.dart'; +import '../authentication/chat_login_page.dart'; +import '../chat_model.dart'; +import 'chat_master/chat_master_detail_page.dart'; + +class ChatStartPage extends StatefulWidget { + const ChatStartPage({super.key}); + + @override + State createState() => _ChatStartPageState(); +} + +class _ChatStartPageState extends State { + late final Future _registrationReady; + + @override + void initState() { + super.initState(); + _registrationReady = di.allReady(); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _registrationReady, + builder: (context, snapshot) => snapshot.hasData + ? (!di().isLogged) + ? const ChatLoginPage() + : const ChatMasterDetailPage() + : const Material( + child: Center( + child: Progress(), + ), + ), + ); +} diff --git a/lib/chat/view/create_room_preset_x.dart b/lib/chat/view/create_room_preset_x.dart new file mode 100644 index 0000000..1b2df7c --- /dev/null +++ b/lib/chat/view/create_room_preset_x.dart @@ -0,0 +1,12 @@ +import 'package:matrix/matrix.dart'; + +import '../../l10n/l10n.dart'; + +extension CreateRoomPresetX on CreateRoomPreset { + String localize(AppLocalizations l10n) => switch (this) { + // TODO: localize + CreateRoomPreset.privateChat => 'Trusted Private Chat', + CreateRoomPreset.publicChat => 'Public Chat', + CreateRoomPreset.trustedPrivateChat => 'Trusted Private Chat', + }; +} diff --git a/lib/chat/view/events/chat_event_column.dart b/lib/chat/view/events/chat_event_column.dart new file mode 100644 index 0000000..df98bd6 --- /dev/null +++ b/lib/chat/view/events/chat_event_column.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../common/date_time_x.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../l10n/l10n.dart'; +import '../../event_x.dart'; +import '../chat_room/chat_seen_by_indicator.dart'; +import 'chat_event_tile.dart'; + +class ChatEventColumn extends StatelessWidget { + const ChatEventColumn({ + super.key, + required this.event, + this.maybePreviousEvent, + required this.jump, + required this.showSeenByIndicator, + required this.timeline, + required this.room, + }); + + final Event event; + final Timeline timeline; + final Room room; + final Event? maybePreviousEvent; + final Future Function(Event event) jump; + final bool showSeenByIndicator; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (maybePreviousEvent != null && + event.originServerTs.toLocal().day != + maybePreviousEvent?.originServerTs.toLocal().day) + Text( + maybePreviousEvent!.originServerTs + .toLocal() + .formatAndLocalizeDay(context.l10n), + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall, + ), + if (!hideEventInTimeline(event: event)) + ChatEventTile( + event: event, + timeline: timeline, + onReplyOriginClick: jump, + partOfMessageCohort: _partOfMessageCohort( + event, + maybePreviousEvent, + ), + ), + if (showSeenByIndicator) + ChatSeenByIndicator( + room: room, + timeline: timeline, + ), + ], + ); + } + + bool hideEventInTimeline({required Event event}) => { + EventTypes.Redaction, + EventTypes.Reaction, + }.contains(event.type); + + bool _partOfMessageCohort(Event event, Event? maybePreviousEvent) { + return maybePreviousEvent != null && + !maybePreviousEvent.showAsBadge && + !maybePreviousEvent.isImage && + maybePreviousEvent.senderId == event.senderId; + } +} diff --git a/lib/chat/view/events/chat_event_status_icon.dart b/lib/chat/view/events/chat_event_status_icon.dart new file mode 100644 index 0000000..7d130ad --- /dev/null +++ b/lib/chat/view/events/chat_event_status_icon.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/date_time_x.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_model.dart'; +import '../../event_x.dart'; + +class ChatEventStatusIcon extends StatelessWidget { + const ChatEventStatusIcon({ + super.key, + required this.event, + this.padding, + this.foregroundColor, + this.textStyle, + }); + + final Event event; + final EdgeInsetsGeometry? padding; + final Color? foregroundColor; + final TextStyle? textStyle; + + static const iconSize = 15.0; + + @override + Widget build(BuildContext context) { + final userEvent = di().isUserEvent(event); + final icon = event.messageType == MessageTypes.BadEncrypted + ? Icon( + YaruIcons.lock, + size: iconSize, + color: foregroundColor, + ) + : userEvent + ? switch (event.status) { + EventStatus.sending || EventStatus.sent => Icon( + key: ValueKey(event.status.index), + YaruIcons.sync, + size: iconSize, + color: foregroundColor, + ), + EventStatus.error => Icon( + key: ValueKey(event.status.index), + YaruIcons.sync_error, + size: iconSize, + color: foregroundColor, + ), + EventStatus.roomState => Icon( + key: ValueKey(event.status.index), + YaruIcons.information, + color: foregroundColor, + ), + EventStatus.synced => Icon( + key: ValueKey(event.status.index), + YaruIcons.checkmark, + size: iconSize, + color: foregroundColor, + ) + } + : const SizedBox.shrink(); + + final style = textStyle ?? + context.textTheme.labelSmall?.copyWith( + color: foregroundColor, + ); + + return Padding( + padding: padding ?? const EdgeInsets.all(kMediumPadding), + child: SizedBox( + height: iconSize, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: kSmallPadding, + children: [ + if (!userEvent && event.isImage) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + '${event.senderFromMemoryOrFallback.calcDisplayname()}, ', + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + event.originServerTs.toLocal().formatAndLocalize(context.l10n), + textAlign: TextAlign.start, + style: style, + overflow: TextOverflow.ellipsis, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: icon, + ), + ], + ), + ), + ); + } +} diff --git a/lib/chat/view/events/chat_event_tile.dart b/lib/chat/view/events/chat_event_tile.dart new file mode 100644 index 0000000..d22cc1e --- /dev/null +++ b/lib/chat/view/events/chat_event_tile.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../common/view/ui_constants.dart'; +import '../../event_x.dart'; +import '../chat_avatar.dart'; +import '../chat_image.dart'; +import '../chat_profile_dialog.dart'; +import 'chat_message_badge.dart'; +import 'chat_message_bubble.dart'; +import 'chat_message_image_full_screen_dialog.dart'; + +class ChatEventTile extends StatelessWidget { + const ChatEventTile({ + super.key, + required this.event, + required this.timeline, + required this.onReplyOriginClick, + this.partOfMessageCohort = false, + }); + + final Event event; + final bool partOfMessageCohort; + final Timeline timeline; + final Future Function(Event) onReplyOriginClick; + + @override + Widget build(BuildContext context) { + if (event.type == EventTypes.RoomMember) { + return ChatMessageBadge( + displayEvent: event.getDisplayEvent(timeline), + leading: Padding( + padding: const EdgeInsets.only(right: kSmallPadding), + child: ChatAvatar( + fallBackIconSize: 10, + dimension: 15, + avatarUri: event.senderFromMemoryOrFallback.avatarUrl, + onTap: () => showDialog( + context: context, + builder: (context) => ChatProfileDialog(userId: event.senderId), + ), + ), + ), + ); + } + if (event.showAsBadge) { + return ChatMessageBadge(displayEvent: event.getDisplayEvent(timeline)); + } + return switch (event.messageType) { + MessageTypes.Image => ChatImage( + timeline: timeline, + event: event, + onTap: () => showDialog( + context: context, + builder: (context) => + ChatMessageImageFullScreenDialog(event: event), + ), + ), + MessageTypes.File || + MessageTypes.Video || + MessageTypes.Audio || + MessageTypes.Text || + MessageTypes.Notice || + MessageTypes.Emote || + MessageTypes.BadEncrypted => + ChatMessageBubble( + event: event, + timeline: timeline, + onReplyOriginClick: onReplyOriginClick, + partOfMessageCohort: partOfMessageCohort, + ), + _ => ChatMessageBadge(displayEvent: event.getDisplayEvent(timeline)) + }; + } +} diff --git a/lib/chat/view/events/chat_html_message.dart b/lib/chat/view/events/chat_html_message.dart new file mode 100644 index 0000000..95bfa20 --- /dev/null +++ b/lib/chat/view/events/chat_html_message.dart @@ -0,0 +1,327 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_highlighter/flutter_highlighter.dart'; +import 'package:flutter_highlighter/themes/dracula.dart'; +import 'package:flutter_highlighter/themes/vs.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:html/dom.dart' as dom; +import 'package:linkify/linkify.dart'; +import 'package:matrix/matrix.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/confirm.dart'; +import '../../../l10n/l10n.dart'; +import '../mxc_image.dart'; + +class HtmlMessage extends StatelessWidget { + final String html; + final Room room; + final Color defaultTextColor; + + const HtmlMessage({ + super.key, + required this.html, + required this.room, + required this.defaultTextColor, + }); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final fontSize = theme.textTheme.bodyMedium?.fontSize ?? 12; + + final element = _linkifyHtml(HtmlParser.parseHTML(html)); + + return Html.fromElement( + documentElement: element as dom.Element, + style: {}, + extensions: [ + CodeExtension(fontSize: fontSize, isLight: theme.colorScheme.isLight), + SpoilerExtension(textColor: defaultTextColor), + const ImageExtension(), + FontColorExtension(), + FallbackTextExtension(fontSize: fontSize), + ], + onLinkTap: (url, attributes, element) { + if (url != null && Uri.tryParse(url) != null) { + showDialog( + context: context, + builder: (context) => ConfirmationDialog( + title: Text( + '${context.l10n.openLinkInBrowser}?', + ), + content: SizedBox( + width: 400, + child: Text(url), + ), + onConfirm: () => launchUrl(Uri.parse(url)), + ), + ); + } + }, + onlyRenderTheseTags: const { + ..._allowedHtmlTags, + 'body', + 'html', + }, + shrinkWrap: true, + ); + } + + dom.Node _linkifyHtml(dom.Node element) { + for (final node in element.nodes) { + if (node is! dom.Text || + (element is dom.Element && element.localName == 'code')) { + node.replaceWith(_linkifyHtml(node)); + continue; + } + + final parts = linkify( + node.text, + options: const LinkifyOptions(humanize: false), + ); + + if (!parts.any((part) => part is UrlElement)) { + continue; + } + + final newHtml = parts + .map( + (linkifyElement) => linkifyElement is! UrlElement + ? linkifyElement.text.replaceAll('<', '<') + : '${linkifyElement.text}', + ) + .join(' '); + + node.replaceWith(dom.Element.html('

$newHtml

')); + } + return element; + } +} + +class FontColorExtension extends HtmlExtension { + static const String colorAttribute = 'color'; + static const String mxColorAttribute = 'data-mx-color'; + static const String bgColorAttribute = 'data-mx-bg-color'; + + @override + Set get supportedTags => {'font', 'span'}; + + @override + bool matches(ExtensionContext context) { + if (!supportedTags.contains(context.elementName)) return false; + return context.element?.attributes.keys.any( + { + colorAttribute, + mxColorAttribute, + bgColorAttribute, + }.contains, + ) ?? + false; + } + + Color? hexToColor(String? hexCode) { + if (hexCode == null) return null; + if (hexCode.startsWith('#')) hexCode = hexCode.substring(1); + if (hexCode.length == 6) hexCode = 'FF$hexCode'; + final colorValue = int.tryParse(hexCode, radix: 16); + return colorValue == null ? null : Color(colorValue); + } + + @override + InlineSpan build( + ExtensionContext context, + ) { + final colorText = context.element?.attributes[colorAttribute] ?? + context.element?.attributes[mxColorAttribute]; + final bgColor = context.element?.attributes[bgColorAttribute]; + return TextSpan( + style: TextStyle( + color: hexToColor(colorText), + backgroundColor: hexToColor(bgColor), + ), + text: context.innerHtml, + ); + } +} + +class ImageExtension extends HtmlExtension { + final double defaultDimension; + + const ImageExtension({this.defaultDimension = 64}); + + @override + Set get supportedTags => {'img'}; + + @override + InlineSpan build(ExtensionContext context) { + final mxcUrl = Uri.tryParse(context.attributes['src'] ?? ''); + if (mxcUrl == null || mxcUrl.scheme != 'mxc') { + return TextSpan(text: context.attributes['alt']); + } + + final width = double.tryParse(context.attributes['width'] ?? ''); + final height = double.tryParse(context.attributes['height'] ?? ''); + + final actualWidth = width ?? height ?? defaultDimension; + final actualHeight = height ?? width ?? defaultDimension; + + return WidgetSpan( + child: SizedBox( + width: actualWidth, + height: actualHeight, + child: MxcImage( + uri: mxcUrl, + width: actualWidth, + height: actualHeight, + // isThumbnail: (actualWidth * actualHeight) > (256 * 256), + ), + ), + ); + } +} + +class SpoilerExtension extends HtmlExtension { + final Color textColor; + + const SpoilerExtension({required this.textColor}); + + @override + Set get supportedTags => {'span'}; + + static const String customDataAttribute = 'data-mx-spoiler'; + + @override + bool matches(ExtensionContext context) { + if (context.elementName != 'span') return false; + return context.element?.attributes.containsKey(customDataAttribute) ?? + false; + } + + @override + InlineSpan build(ExtensionContext context) { + var obscure = true; + final children = context.inlineSpanChildren; + return WidgetSpan( + child: StatefulBuilder( + builder: (context, setState) { + return InkWell( + onTap: () => setState(() { + obscure = !obscure; + }), + child: RichText( + text: TextSpan( + style: obscure ? TextStyle(backgroundColor: textColor) : null, + children: children, + ), + ), + ); + }, + ), + ); + } +} + +class CodeExtension extends HtmlExtension { + final double fontSize; + final bool isLight; + + CodeExtension({ + required this.fontSize, + required this.isLight, + }); + @override + Set get supportedTags => {'code', 'pre'}; + + @override + InlineSpan build(ExtensionContext context) => WidgetSpan( + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: HighlightView( + padding: EdgeInsets.zero, + context.element?.text ?? '', + language: context.element?.className + .split(' ') + .singleWhereOrNull( + (className) => className.startsWith('language-'), + ) + ?.split('language-') + .last ?? + 'md', + theme: isLight ? vsTheme : draculaTheme, + textStyle: TextStyle( + fontSize: fontSize, + // fontFamily: 'UbuntuMono', + ), + ), + ), + ); +} + +class FallbackTextExtension extends HtmlExtension { + final double fontSize; + + FallbackTextExtension({required this.fontSize}); + @override + Set get supportedTags => _fallbackTextTags; + + @override + InlineSpan build(ExtensionContext context) => TextSpan( + text: context.element?.text ?? '', + style: TextStyle( + fontSize: fontSize, + ), + ); +} + +const Set _fallbackTextTags = {'tg-forward'}; + +/// Keep in sync with: https://spec.matrix.org/v1.6/client-server-api/#mroommessage-msgtypes +const Set _allowedHtmlTags = { + 'font', + 'del', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'p', + 'a', + 'ul', + 'ol', + 'sup', + 'sub', + 'li', + 'b', + 'i', + 'u', + 'strong', + 'em', + 'strike', + 'code', + 'hr', + 'br', + 'div', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'caption', + 'pre', + 'span', + 'img', + 'details', + 'summary', + // Not in the allowlist of the matrix spec yet but should be harmless: + 'ruby', + 'rp', + 'rt', + // Workaround for https://github.com/krille-chan/fluffychat/issues/507 + ..._fallbackTextTags, +}; diff --git a/lib/chat/view/events/chat_message_attachment_indicator.dart b/lib/chat/view/events/chat_message_attachment_indicator.dart new file mode 100644 index 0000000..a66c8ed --- /dev/null +++ b/lib/chat/view/events/chat_message_attachment_indicator.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../chat_download_model.dart'; + +class ChatMessageAttachmentIndicator extends StatelessWidget with WatchItMixin { + const ChatMessageAttachmentIndicator({ + super.key, + required this.event, + this.iconSize = 15.0, + this.color, + }); + + final Event event; + final double iconSize; + final Color? color; + + @override + Widget build(BuildContext context) { + final isEventDownload = watchPropertyValue( + (ChatDownloadModel m) => m.isEventDownloaded(event), + ); + + // TODO: open with native file manager + return isEventDownload != null + ? Tooltip( + message: isEventDownload, + child: Icon( + YaruIcons.download_filled, + color: context.colorScheme.primary, + size: iconSize, + ), + ) + : Icon( + YaruIcons.download, + size: iconSize, + color: color, + ); + } +} diff --git a/lib/chat/view/events/chat_message_badge.dart b/lib/chat/view/events/chat_message_badge.dart new file mode 100644 index 0000000..d2c9e14 --- /dev/null +++ b/lib/chat/view/events/chat_message_badge.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import 'localized_display_event_text.dart'; + +class ChatMessageBadge extends StatelessWidget { + const ChatMessageBadge({ + super.key, + required this.displayEvent, + this.leading, + }); + + final Event displayEvent; + final Widget? leading; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + return Padding( + padding: const EdgeInsets.all(kSmallPadding), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null) leading!, + Flexible( + child: Badge( + textColor: getEventBadgeTextColor(theme), + backgroundColor: getEventBadgeColor(theme), + label: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 200, + minWidth: 30, + minHeight: 16, + maxHeight: 16, + ), + child: LocalizedDisplayEventText( + displayEvent: displayEvent, + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/chat/view/events/chat_message_bubble.dart b/lib/chat/view/events/chat_message_bubble.dart new file mode 100644 index 0000000..fab85fb --- /dev/null +++ b/lib/chat/view/events/chat_message_bubble.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../../app_config.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/theme.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_download_model.dart'; +import '../../chat_model.dart'; +import '../chat_avatar.dart'; +import '../chat_profile_dialog.dart'; +import 'chat_event_status_icon.dart'; +import 'chat_html_message.dart'; +import 'chat_message_attachment_indicator.dart'; +import 'chat_message_bubble_shape.dart'; +import 'chat_message_media_avatar.dart'; +import 'chat_message_menu.dart'; +import 'chat_message_reactions.dart'; +import 'chat_message_reply_header.dart'; +import 'localized_display_event_text.dart'; + +class ChatMessageBubble extends StatelessWidget with WatchItMixin { + const ChatMessageBubble({ + super.key, + required this.event, + required this.timeline, + this.messageBubbleShape = ChatMessageBubbleShape.allRound, + required this.onReplyOriginClick, + this.partOfMessageCohort = false, + }); + + final Event event; + final Timeline timeline; + final ChatMessageBubbleShape messageBubbleShape; + final Future Function(Event event) onReplyOriginClick; + final bool partOfMessageCohort; + + static const width = 450.0; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final isUserMessage = di().isUserEvent(event); + + return Stack( + children: [ + Align( + alignment: + isUserMessage ? Alignment.centerRight : Alignment.centerLeft, + child: Stack( + children: [ + ChatMessageMenu( + event: event, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ChatMessageBubble.width, + minWidth: 205, + ), + child: Container( + margin: tilePadding(partOfMessageCohort), + padding: const EdgeInsets.all(kSmallPadding), + decoration: BoxDecoration( + color: getTileColor( + di().isUserEvent(event), + theme, + ), + borderRadius: messageBubbleShape + .getBorderRadius(partOfMessageCohort), + ), + child: _ChatMessageBubbleContent( + event: event, + timeline: timeline, + onReplyOriginClick: onReplyOriginClick, + hideAvatar: partOfMessageCohort, + ), + ), + ), + ), + if (!event.redacted) + Positioned( + key: ValueKey('${event.eventId}reactions'), + left: kSmallPadding, + bottom: kSmallPadding, + child: ChatMessageReactions( + event: event, + timeline: timeline, + ), + ), + Positioned( + bottom: kSmallPadding, + right: kSmallPadding, + child: ChatEventStatusIcon(event: event), + ), + Positioned( + top: kBigPadding, + right: kBigPadding, + child: event.attachmentMxcUrl == null + ? const SizedBox.shrink() + : InkWell( + onTap: () => di().safeFile(event), + child: ChatMessageAttachmentIndicator(event: event), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _ChatMessageBubbleContent extends StatelessWidget { + const _ChatMessageBubbleContent({ + required this.event, + required this.timeline, + required this.onReplyOriginClick, + required this.hideAvatar, + }); + + final Event event; + final Timeline timeline; + final Future Function(Event event) onReplyOriginClick; + final bool hideAvatar; + + @override + Widget build(BuildContext context) { + var html = event.formattedText; + if (event.messageType == MessageTypes.Emote) { + html = '* $html'; + } + final textTheme = context.textTheme; + final messageStyle = textTheme.bodyMedium; + final displayEvent = event.getDisplayEvent(timeline); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: kSmallPadding, + children: [ + Padding( + padding: const EdgeInsets.all(kSmallPadding), + child: event.messageType != MessageTypes.Text + ? ChatMessageMediaAvatar(event: event) + : hideAvatar + ? const SizedBox.shrink() + : ChatAvatar( + avatarUri: event.senderFromMemoryOrFallback.avatarUrl, + onTap: () => showDialog( + context: context, + builder: (context) => + ChatProfileDialog(userId: event.senderId), + ), + fallBackColor: getMonochromeBg( + theme: context.theme, + factor: 10, + darkFactor: yaru ? 1 : null, + ), + ), + ), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: kSmallPadding, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + event.senderFromMemoryOrFallback.calcDisplayname(), + style: textTheme.labelSmall, + ), + ), + if (!event.redacted) + Flexible( + child: ChatMessageReplyHeader( + event: event, + timeline: timeline, + onReplyOriginClick: onReplyOriginClick, + ), + ), + ], + ), + Opacity( + opacity: event.redacted ? 0.5 : 1, + child: event.redacted + ? LocalizedDisplayEventText( + displayEvent: displayEvent, + style: messageStyle?.copyWith( + decoration: TextDecoration.lineThrough, + ), + ) + : event.isRichMessage + ? HtmlMessage( + html: html, + room: timeline.room, + defaultTextColor: context.colorScheme.onSurface, + ) + : SelectableText.rich( + TextSpan( + style: messageStyle, + text: displayEvent.body, + ), + style: messageStyle, + ), + ), + const SizedBox( + height: kBigPadding, + ), + ], + ), + ), + const SizedBox( + height: kSmallPadding, + ), + ], + ); + } +} diff --git a/lib/chat/view/events/chat_message_bubble_shape.dart b/lib/chat/view/events/chat_message_bubble_shape.dart new file mode 100644 index 0000000..f7520c2 --- /dev/null +++ b/lib/chat/view/events/chat_message_bubble_shape.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../../../common/view/ui_constants.dart'; + +enum ChatMessageBubbleShape { + topRound, + allRound, + bottomRound; + + BorderRadius getBorderRadius( + bool partOfMessageCascade, { + bool header = false, + }) => + switch (this) { + topRound => BorderRadius.only( + topLeft: kBigBubbleRadius, + topRight: kBigBubbleRadius, + bottomRight: header ? Radius.zero : kBubbleRadius, + bottomLeft: header ? Radius.zero : kBubbleRadius, + ), + bottomRound => const BorderRadius.only( + topLeft: kBubbleRadius, + topRight: kBubbleRadius, + bottomRight: kBigBubbleRadius, + bottomLeft: kBigBubbleRadius, + ), + allRound => const BorderRadius.only( + topLeft: kBigBubbleRadius, + topRight: kBigBubbleRadius, + bottomRight: kBigBubbleRadius, + bottomLeft: kBigBubbleRadius, + ), + }; +} diff --git a/lib/chat/view/events/chat_message_image_full_screen_dialog.dart b/lib/chat/view/events/chat_message_image_full_screen_dialog.dart new file mode 100644 index 0000000..20b87ff --- /dev/null +++ b/lib/chat/view/events/chat_message_image_full_screen_dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/date_time_x.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_download_model.dart'; +import '../chat_image.dart'; + +class ChatMessageImageFullScreenDialog extends StatelessWidget { + const ChatMessageImageFullScreenDialog({super.key, required this.event}); + + final Event event; + + @override + Widget build(BuildContext context) => AlertDialog( + scrollable: true, + titlePadding: EdgeInsets.zero, + title: YaruDialogTitleBar( + title: Row( + mainAxisSize: MainAxisSize.min, + spacing: kSmallPadding, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + '${event.senderFromMemoryOrFallback.calcDisplayname()}, ', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + event.originServerTs.toLocal().formatAndLocalize(context.l10n), + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ), + const AnimatedSwitcher( + duration: Duration(milliseconds: 300), + ), + ], + ), + border: BorderSide.none, + backgroundColor: Colors.transparent, + actions: [ + IconButton( + tooltip: context.l10n.downloadFile, + onPressed: () => di().safeFile(event), + icon: const Icon(YaruIcons.download), + ), + ], + ), + content: SizedBox( + width: context.mediaQuerySize.width, + height: context.mediaQuerySize.height - 150, + child: ChatImageFuture( + width: context.mediaQuerySize.width, + height: context.mediaQuerySize.height - 150, + fit: BoxFit.fitWidth, + event: event, + fromCache: false, + ), + ), + ); +} diff --git a/lib/chat/view/events/chat_message_media_avatar.dart b/lib/chat/view/events/chat_message_media_avatar.dart new file mode 100644 index 0000000..3d06947 --- /dev/null +++ b/lib/chat/view/events/chat_message_media_avatar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/confirm.dart'; +import '../../../l10n/l10n.dart'; +import '../../chat_download_model.dart'; + +class ChatMessageMediaAvatar extends StatelessWidget { + const ChatMessageMediaAvatar({ + super.key, + required this.event, + }); + + final Event event; + + @override + Widget build(BuildContext context) { + return Material( + borderRadius: BorderRadius.circular(38 / 2), + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(38 / 2), + onTap: () => showDialog( + context: context, + builder: (context) => ConfirmationDialog( + title: Text(context.l10n.saveFile), + onConfirm: () { + Navigator.of(context).pop(); + + di().safeFile(event); + }, + ), + ), + child: CircleAvatar( + radius: 38 / 2, + child: switch (event.messageType) { + MessageTypes.Audio => const Icon(YaruIcons.media_play), + MessageTypes.Video => const Icon(YaruIcons.video_filled), + _ => const Icon(YaruIcons.document_filled), + }, + ), + ), + ); + } +} diff --git a/lib/chat/view/events/chat_message_menu.dart b/lib/chat/view/events/chat_message_menu.dart new file mode 100644 index 0000000..0321339 --- /dev/null +++ b/lib/chat/view/events/chat_message_menu.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/snackbars.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../../l10n/l10n.dart'; +import '../../data/emojis.dart'; +import '../../draft_model.dart'; +import '../../room_x.dart'; + +class ChatMessageMenu extends StatelessWidget { + const ChatMessageMenu({ + super.key, + required this.event, + required this.child, + }); + + final Event event; + final Widget child; + + @override + Widget build(BuildContext context) { + final style = context.textTheme.bodyMedium; + return MenuAnchor( + consumeOutsideTap: true, + alignmentOffset: const Offset(kMediumPadding, -kMediumPadding), + builder: + (BuildContext context, MenuController controller, Widget? child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: child, + ); + }, + menuChildren: [ + if (event.canRedact) + MenuItemButton( + trailingIcon: const Icon(YaruIcons.trash), + onPressed: () => event.room.redactEvent(event.eventId), + child: Text( + context.l10n.deleteMessage, + style: style, + ), + ), + MenuItemButton( + trailingIcon: const Icon(YaruIcons.reply), + onPressed: () => di().setReplyEvent(event), + child: Text( + context.l10n.reply, + style: style, + ), + ), + if (event.room.canEdit == true) + MenuItemButton( + trailingIcon: const Icon(YaruIcons.pen), + onPressed: () { + di() + ..setEditEvent(roomId: event.room.id, event: event) + ..setDraft( + roomId: event.room.id, + draft: event.plaintextBody, + notify: true, + ); + }, + child: Text( + context.l10n.edit, + style: style, + ), + ), + MenuItemButton( + trailingIcon: const Icon(YaruIcons.copy), + child: Text( + context.l10n.copyToClipboard, + style: style, + ), + onPressed: () => showSnackBar( + context, + content: CopyClipboardContent( + text: event.body, + ), + ), + ), + ChatMessageReactionPicker(event: event), + ], + child: child, + ); + } +} + +class ChatMessageReactionPicker extends StatelessWidget { + const ChatMessageReactionPicker({ + super.key, + required this.event, + }); + + final Event event; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 220, + height: 220, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + mainAxisExtent: 40, + crossAxisCount: 5, + ), + itemCount: emojis.length, + itemBuilder: (context, i) => IconButton( + padding: EdgeInsets.zero, + onPressed: () { + event.room.sendReaction( + event.eventId, + emojis[i], + ); + }, + icon: Text( + emojis[i], + style: context.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/events/chat_message_reactions.dart b/lib/chat/view/events/chat_message_reactions.dart new file mode 100644 index 0000000..9ae4a51 --- /dev/null +++ b/lib/chat/view/events/chat_message_reactions.dart @@ -0,0 +1,227 @@ +import 'dart:math'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/material.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app_config.dart'; +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/common_widgets.dart'; +import '../../../common/view/space.dart'; +import '../../../common/view/ui_constants.dart'; +import '../../chat_model.dart'; +import '../chat_avatar.dart'; +import '../mxc_image.dart'; + +class ChatMessageReactions extends StatelessWidget { + final Event event; + final Timeline timeline; + + const ChatMessageReactions({ + required this.event, + required this.timeline, + super.key, + }); + + @override + Widget build(BuildContext context) { + final allReactionEvents = + event.aggregatedEvents(timeline, RelationshipTypes.reaction); + final reactionMap = {}; + + for (final e in allReactionEvents) { + final key = e.content + .tryGetMap('m.relates_to') + ?.tryGet('key'); + if (key != null) { + if (!reactionMap.containsKey(key)) { + reactionMap[key] = ReactionEntry( + key: key, + count: 0, + reacted: false, + reactors: [], + ); + } + reactionMap[key]!.count++; + reactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback); + reactionMap[key]!.reacted |= e.senderId == e.room.client.userID; + } + } + + final reactionList = reactionMap.values.toList(); + reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1); + + return Wrap( + spacing: kSmallPadding, + runSpacing: kSmallPadding, + alignment: di().isUserEvent(event) + ? WrapAlignment.end + : WrapAlignment.start, + children: [ + ...reactionList.map( + (r) => _Reaction( + reactionKey: r.key, + count: r.count, + reacted: r.reacted, + onTap: () { + if (r.reacted) { + final evt = allReactionEvents.firstWhereOrNull( + (e) => + e.senderId == e.room.client.userID && + e.content.tryGetMap('m.relates_to')?['key'] == r.key, + ); + if (evt != null) { + showFutureLoadingDialog( + context: context, + future: () => evt.redactEvent(), + ); + } + } else { + event.room.sendReaction(event.eventId, r.key); + } + }, + onLongPress: () => showDialog( + context: context, + builder: (context) => ReactionsModal( + reactionEntry: r, + ), + ), + ), + ), + if (allReactionEvents.any((e) => e.status.isSending)) + const SizedBox( + width: 24, + height: 24, + child: Padding( + padding: EdgeInsets.all(4.0), + child: Progress(strokeWidth: 1), + ), + ), + ], + ); + } +} + +class _Reaction extends StatelessWidget { + final String reactionKey; + final int count; + final bool? reacted; + final void Function()? onTap; + final void Function()? onLongPress; + + const _Reaction({ + required this.reactionKey, + required this.count, + required this.reacted, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final textColor = + theme.brightness == Brightness.dark ? Colors.white : Colors.black; + final color = theme.colorScheme.surface; + Widget content; + if (reactionKey.startsWith('mxc://')) { + content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + MxcImage( + uri: Uri.parse(reactionKey), + width: 20, + height: 20, + ), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + count.toString(), + style: TextStyle( + color: textColor, + fontSize: DefaultTextStyle.of(context).style.fontSize, + ), + ), + ], + ], + ); + } else { + var renderKey = Characters(reactionKey); + if (renderKey.length > 10) { + renderKey = renderKey.getRange(0, 9) + Characters('…'); + } + content = Text( + renderKey.toString() + (count > 1 ? ' $count' : ''), + textAlign: TextAlign.center, + style: TextStyle( + color: textColor, + fontSize: DefaultTextStyle.of(context).style.fontSize, + ), + ); + } + return InkWell( + onTap: () => onTap != null ? onTap!() : null, + onLongPress: () => onLongPress != null ? onLongPress!() : null, + borderRadius: const BorderRadius.all(kBigBubbleRadius), + child: Container( + height: 25, + width: 25, + decoration: BoxDecoration( + color: color, + border: Border.all( + width: 1, + color: reacted! + ? theme.colorScheme.primary + : theme.colorScheme.outline, + ), + borderRadius: const BorderRadius.all(kBigBubbleRadius), + ), + padding: EdgeInsets.only(left: yaru ? 3 : 0), + child: Center(child: content), + ), + ); + } +} + +class ReactionEntry { + ReactionEntry({ + required this.key, + required this.count, + required this.reacted, + this.reactors, + }); + + String key; + int count; + bool reacted; + List? reactors; +} + +class ReactionsModal extends StatelessWidget { + const ReactionsModal({super.key, this.reactionEntry}); + + final ReactionEntry? reactionEntry; + + @override + Widget build(BuildContext context) => SimpleDialog( + title: YaruDialogTitleBar( + title: Text(reactionEntry!.key), + border: BorderSide.none, + backgroundColor: Colors.transparent, + ), + titlePadding: EdgeInsets.zero, + children: space( + heightGap: kSmallPadding, + children: [ + for (final reactor in reactionEntry!.reactors!) + Chip( + avatar: ChatAvatar(avatarUri: reactor.avatarUrl), + label: Text(reactor.displayName!), + ), + ], + ), + ); +} diff --git a/lib/chat/view/events/chat_message_reply_header.dart b/lib/chat/view/events/chat_message_reply_header.dart new file mode 100644 index 0000000..73c5702 --- /dev/null +++ b/lib/chat/view/events/chat_message_reply_header.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../../common/view/ui_constants.dart'; + +class ChatMessageReplyHeader extends StatefulWidget { + const ChatMessageReplyHeader({ + super.key, + required this.event, + required this.timeline, + required this.onReplyOriginClick, + }); + + final Event event; + final Timeline timeline; + final Future Function(Event event) onReplyOriginClick; + + @override + State createState() => _ChatMessageReplyHeaderState(); +} + +class _ChatMessageReplyHeaderState extends State { + late final Future _future; + + static final Map _cache = {}; + + @override + void initState() { + super.initState(); + _future = widget.event.getReplyEvent(widget.timeline); + } + + @override + Widget build(BuildContext context) { + // TODO: cache + // final fromCache = _cache[widget.event.eventId]; + + // if (fromCache != null) { + // return fromCache.redacted + // ? Container() + // : _Message( + // replyEvent: fromCache, + // onReplyOriginClick: widget.onReplyOriginClick, + // ); + // } + + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + final replyEvent = snapshot.data; + + _cache.update( + widget.event.eventId, + (value) => replyEvent!, + ifAbsent: () => replyEvent!, + ); + + if (replyEvent!.redacted) { + return Container(); + } + + return _Message( + replyEvent: replyEvent, + onReplyOriginClick: widget.onReplyOriginClick, + ); + } + + return Container(); + }, + ); + } +} + +class _Message extends StatelessWidget { + const _Message({ + required this.onReplyOriginClick, + required this.replyEvent, + }); + + final Future Function(Event) onReplyOriginClick; + final Event? replyEvent; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: kSmallPadding), + child: InkWell( + onTap: + replyEvent == null ? null : () => onReplyOriginClick(replyEvent!), + child: Text( + '> (${replyEvent?.senderFromMemoryOrFallback.calcDisplayname()}): ${replyEvent?.body}', + maxLines: 1, + style: context.textTheme.labelSmall?.copyWith( + fontStyle: FontStyle.italic, + overflow: TextOverflow.ellipsis, + color: Colors.lightBlue, + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/events/localized_display_event_text.dart b/lib/chat/view/events/localized_display_event_text.dart new file mode 100644 index 0000000..36f75a3 --- /dev/null +++ b/lib/chat/view/events/localized_display_event_text.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../common/view/build_context_x.dart'; +import '../../event_x.dart'; + +class LocalizedDisplayEventText extends StatefulWidget { + const LocalizedDisplayEventText({ + super.key, + required this.displayEvent, + this.style, + this.textAlign, + }); + + final Event displayEvent; + final TextStyle? style; + final TextAlign? textAlign; + + @override + State createState() => + _LocalizedDisplayEventTextState(); +} + +class _LocalizedDisplayEventTextState extends State { + late final Future _future; + static final Map _cache = {}; + + @override + void initState() { + super.initState(); + _future = widget.displayEvent + .calcLocalizedBody(const MatrixDefaultLocalizations()); + } + + @override + Widget build(BuildContext context) => + _cache.containsKey(widget.displayEvent.eventId) + ? _Content( + text: _cache[widget.displayEvent.eventId]!, + style: widget.style, + textAlign: widget.textAlign, + badge: widget.displayEvent.showAsBadge, + ) + : FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + _cache.update( + widget.displayEvent.eventId, + (_) => snapshot.data!, + ifAbsent: () => snapshot.data!, + ); + } + + return AnimatedOpacity( + opacity: snapshot.hasData ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: _Content( + text: snapshot.data ?? widget.displayEvent.body, + style: widget.style, + textAlign: widget.textAlign, + badge: widget.displayEvent.showAsBadge, + ), + ); + }, + ); +} + +class _Content extends StatelessWidget { + const _Content({ + required this.text, + this.style, + this.textAlign, + this.badge = false, + }); + + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final bool badge; + + @override + Widget build(BuildContext context) { + final theStyle = (style ?? context.textTheme.labelSmall) + ?.copyWith(overflow: TextOverflow.ellipsis); + final align = textAlign; + + if (badge) { + return Text(text, textAlign: align, style: theStyle); + } + + return SelectableText.rich( + TextSpan(text: text), + textAlign: align, + style: theStyle, + ); + } +} diff --git a/lib/chat/view/mxc_image.dart b/lib/chat/view/mxc_image.dart new file mode 100644 index 0000000..a839894 --- /dev/null +++ b/lib/chat/view/mxc_image.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; +import '../local_image_model.dart'; +import '../../common/view/image_shimmer.dart'; + +class MxcImage extends StatelessWidget with WatchItMixin { + const MxcImage({ + super.key, + required this.uri, + this.width, + this.height, + this.dimension, + this.fit, + }); + + final Uri uri; + final double? width; + final double? height; + final double? dimension; + final BoxFit? fit; + + @override + Widget build(BuildContext context) { + final maybeImage = + watchPropertyValue((LocalImageModel m) => m.get(uri.toString())); + + final theHeight = dimension ?? height; + final theWidth = dimension ?? width; + final theFit = fit ?? BoxFit.cover; + + return maybeImage != null + ? Image.memory( + maybeImage, + fit: theFit, + height: theHeight, + width: theWidth, + ) + : MxcImageFuture( + uri: uri, + width: theWidth, + height: theHeight, + fit: theFit, + ); + } +} + +class MxcImageFuture extends StatefulWidget { + const MxcImageFuture({ + super.key, + required this.uri, + this.height, + this.width, + this.fit, + }); + + final Uri uri; + final double? height; + final double? width; + final BoxFit? fit; + + @override + State createState() => _MxcImageFutureState(); +} + +class _MxcImageFutureState extends State { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = di().downloadMxcCached(uri: widget.uri); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = snapshot.data; + return Image.memory( + data!, + fit: widget.fit, + height: widget.height, + width: widget.width, + ); + } + + return const ImageShimmer(); + }, + ); +} diff --git a/lib/chat/view/no_selected_room_page.dart b/lib/chat/view/no_selected_room_page.dart new file mode 100644 index 0000000..bac81ae --- /dev/null +++ b/lib/chat/view/no_selected_room_page.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/build_context_x.dart'; +import 'side_bar_button.dart'; + +class NoSelectedRoomPage extends StatelessWidget { + const NoSelectedRoomPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: YaruWindowTitleBar( + heroTag: '', + border: BorderSide.none, + backgroundColor: Colors.transparent, + title: const Text(''), + leading: !kIsWeb && !Platform.isMacOS && !context.showSideBar + ? const SideBarButton() + : null, + actions: [ + if (!context.showSideBar && !kIsWeb && Platform.isMacOS) + const SideBarButton(), + ], + ), + body: const Center( + child: Text('Please select a chatroom'), + ), + ); +} diff --git a/lib/chat/view/search_auto_complete.dart b/lib/chat/view/search_auto_complete.dart new file mode 100644 index 0000000..03c8a25 --- /dev/null +++ b/lib/chat/view/search_auto_complete.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:matrix/matrix.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../common/view/build_context_x.dart'; +import '../../common/view/snackbars.dart'; +import '../../common/view/ui_constants.dart'; +import '../../l10n/l10n.dart'; +import '../chat_model.dart'; +import '../search_model.dart'; +import 'chat_avatar.dart'; + +class SearchAutoComplete extends StatelessWidget with WatchItMixin { + const SearchAutoComplete({ + super.key, + required this.suffix, + this.onProfileSelected, + this.width, + }); + + final Widget suffix; + final void Function(Profile)? onProfileSelected; + final double? width; + + @override + Widget build(BuildContext context) { + final model = di(); + final theme = context.theme; + + final processingJoinOrLeave = + watchPropertyValue((ChatModel m) => m.processingJoinOrLeave); + + return Autocomplete( + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) => + TextField( + enabled: !processingJoinOrLeave, + decoration: InputDecoration( + hintText: '${context.l10n.search} ${context.l10n.users}', + label: Text('${context.l10n.search} ${context.l10n.users}'), + suffixIcon: suffix, + ), + controller: textEditingController, + onSubmitted: (value) => onFieldSubmitted(), + focusNode: focusNode, + autofocus: true, + ), + onSelected: (option) { + if (onProfileSelected != null) { + onProfileSelected!(option); + } else { + model.joinDirectChat( + option.userId, + onFail: (error) => showSnackBar(context, content: Text(error)), + ); + } + }, + displayStringForOption: (profile) => + profile.displayName ?? profile.userId, + optionsBuilder: (textEditingValue) async => + await di().findUserProfiles( + textEditingValue.text, + onFail: () => showSnackBar( + context, + // TODO: localize + content: const Text( + 'User search not available', + ), + ), + ) ?? + [], + optionsViewBuilder: (context, onSelected, options) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width ?? 220, + height: (options.length * 50) > 400 ? 400 : options.length * 60, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Material( + color: theme.popupMenuTheme.color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide( + color: theme.dividerColor, + width: 1, + ), + ), + elevation: 1, + child: ListView.builder( + itemCount: options.length, + itemBuilder: (context, index) { + return Builder( + builder: (BuildContext context) { + final bool highlight = AutocompleteHighlightedOption.of( + context, + ) == + index; + if (highlight) { + SchedulerBinding.instance + .addPostFrameCallback((Duration timeStamp) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + ); + }); + } + final t = options.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: kSmallPadding), + child: ListTile( + key: ValueKey(t.userId), + leading: ChatAvatar(avatarUri: t.avatarUrl), + title: Text( + t.displayName ?? t.userId, + maxLines: 1, + ), + subtitle: Text( + t.userId, + maxLines: 1, + ), + onTap: () => onSelected(t), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ), + ); + } +} + +class RoomsAutoComplete extends StatelessWidget with WatchItMixin { + const RoomsAutoComplete({super.key, required this.suffix}); + + final Widget suffix; + + @override + Widget build(BuildContext context) { + final model = di(); + final theme = context.theme; + + final processingJoinOrLeave = + watchPropertyValue((ChatModel m) => m.processingJoinOrLeave); + + final searchType = watchPropertyValue((SearchModel m) => m.searchType); + final label = + '${context.l10n.search} ${searchType == SearchType.spaces ? context.l10n.spaces : context.l10n.publicRooms}'; + + return Autocomplete( + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) { + return TextField( + enabled: !processingJoinOrLeave, + decoration: InputDecoration( + hintText: label, + label: Text(label), + suffixIcon: suffix, + ), + controller: textEditingController, + onSubmitted: (value) => onFieldSubmitted(), + focusNode: focusNode, + autofocus: true, + ); + }, + onSelected: (option) => model.joinAndSelectRoomByChunk( + option, + onFail: (error) => showSnackBar(context, content: Text(error)), + ), + displayStringForOption: (chunk) => chunk.name ?? chunk.roomId, + optionsBuilder: (textEditingValue) => + di().findPublicRoomChunks( + textEditingValue.text, + onFail: (error) => showSnackBar(context, content: Text(error)), + ), + optionsViewBuilder: (context, onSelected, options) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 220, + height: (options.length * 50) > 400 ? 400 : options.length * 60, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Material( + color: theme.popupMenuTheme.color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide( + color: theme.dividerColor, + width: 1, + ), + ), + elevation: 1, + child: ListView.builder( + itemCount: options.length, + itemBuilder: (context, index) { + return Builder( + builder: (BuildContext context) { + final bool highlight = AutocompleteHighlightedOption.of( + context, + ) == + index; + if (highlight) { + SchedulerBinding.instance + .addPostFrameCallback((Duration timeStamp) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + ); + }); + } + final chunk = options.elementAt(index); + return Padding( + padding: const EdgeInsets.only(bottom: kSmallPadding), + child: ListTile( + key: ValueKey(chunk.roomId), + leading: ChatAvatar( + avatarUri: chunk.avatarUrl, + ), + title: Text( + chunk.name ?? chunk.roomId, + maxLines: 1, + ), + subtitle: Text( + chunk.canonicalAlias ?? chunk.roomId, + maxLines: 1, + ), + onTap: () => model.joinAndSelectRoomByChunk( + chunk, + onFail: (error) => + showSnackBar(context, content: Text(error)), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/chat/view/side_bar_button.dart b/lib/chat/view/side_bar_button.dart new file mode 100644 index 0000000..fe5dbef --- /dev/null +++ b/lib/chat/view/side_bar_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/scaffold_state_x.dart'; +import '../../common/view/ui_constants.dart'; +import 'chat_master/chat_master_detail_page.dart'; + +class SideBarButton extends StatelessWidget { + const SideBarButton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kSmallPadding), + child: Center( + child: IconButton( + onPressed: masterScaffoldKey.currentState?.showDrawer, + icon: const Icon(YaruIcons.sidebar), + ), + ), + ); + } +} diff --git a/lib/common/date_time_x.dart b/lib/common/date_time_x.dart new file mode 100644 index 0000000..ef2e7cc --- /dev/null +++ b/lib/common/date_time_x.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/l10n.dart'; + +extension DateTimeX on DateTime { + String formatAndLocalize(AppLocalizations l10n) { + final now = DateTime.now(); + final locale = WidgetsBinding.instance.platformDispatcher.locale; + + if (year == now.year && month == now.month) { + if (day == now.day - 1) { + return '${l10n.yesterday}, ${DateFormat.Hm( + locale.countryCode, + ).format(this)}'; + } else if (day == now.day) { + return DateFormat.Hm( + locale.countryCode, + ).format(this); + } + } + return DateFormat.yMd( + locale.countryCode, + ).add_Hm().format(this); + } + + String formatAndLocalizeDay(AppLocalizations l10n) { + final now = DateTime.now(); + final locale = WidgetsBinding.instance.platformDispatcher.locale; + + if (year == now.year && month == now.month) { + if (day == now.day - 1) { + return l10n.yesterday; + } + } + return DateFormat.yMd( + locale.countryCode, + ).format(this); + } +} diff --git a/lib/common/logging.dart b/lib/common/logging.dart new file mode 100644 index 0000000..398ffc8 --- /dev/null +++ b/lib/common/logging.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart'; + +void printMessageInDebugMode(Object? object, [Object? stack]) { + if (kDebugMode) { + print(object); + if (stack != null) { + print(stack); + } + } +} diff --git a/lib/common/view/build_context_x.dart b/lib/common/view/build_context_x.dart new file mode 100644 index 0000000..0611498 --- /dev/null +++ b/lib/common/view/build_context_x.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'ui_constants.dart'; + +extension BuildContextX on BuildContext { + MediaQueryData get mq => MediaQuery.of(this); + Size get mediaQuerySize => mq.size; + bool get showSideBar => mediaQuerySize.width > kShowSideBarThreshHold; + ThemeData get theme => Theme.of(this); + ColorScheme get colorScheme => theme.colorScheme; + TextTheme get textTheme => theme.textTheme; +} diff --git a/lib/common/view/circle_wave_loader.dart b/lib/common/view/circle_wave_loader.dart new file mode 100644 index 0000000..2b9e4a1 --- /dev/null +++ b/lib/common/view/circle_wave_loader.dart @@ -0,0 +1,32 @@ +import 'build_context_x.dart'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:liquid_progress_indicator_v2/liquid_progress_indicator.dart'; +import 'ui_constants.dart'; + +class CircleWaveLoader extends StatelessWidget { + const CircleWaveLoader({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: AnimatedContainer( + duration: const Duration(seconds: 5), + height: context.mediaQuerySize.height / 2, + width: (context.mediaQuerySize.width - kSideBarWith) / 2, + child: Transform.rotate( + angle: pi * 3 / 2, + child: LiquidCircularProgressIndicator( + borderColor: Colors.transparent, + backgroundColor: Colors.transparent, + borderWidth: 0, + direction: Axis.horizontal, + valueColor: AlwaysStoppedAnimation( + context.colorScheme.primary.withValues(alpha: 0.8), + ), + ), + ), + ), + ); + } +} diff --git a/lib/common/view/common_widgets.dart b/lib/common/view/common_widgets.dart new file mode 100644 index 0000000..7df9e65 --- /dev/null +++ b/lib/common/view/common_widgets.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app_config.dart'; +import 'build_context_x.dart'; + +class CommonSwitch extends StatelessWidget { + const CommonSwitch({super.key, required this.value, this.onChanged}); + + final bool value; + final void Function(bool)? onChanged; + + @override + Widget build(BuildContext context) { + return yaru + ? YaruSwitch( + value: value, + onChanged: onChanged, + ) + : Switch(value: value, onChanged: onChanged); + } +} + +class CommonCheckBox extends StatelessWidget { + const CommonCheckBox({super.key, required this.value, this.onChanged}); + + final bool value; + final void Function(bool?)? onChanged; + + @override + Widget build(BuildContext context) { + return yaru + ? YaruCheckbox( + value: value, + onChanged: onChanged, + ) + : Checkbox(value: value, onChanged: onChanged); + } +} + +class ImportantButton extends StatelessWidget { + const ImportantButton({ + super.key, + required this.onPressed, + required this.child, + }); + + final void Function()? onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return yaru + ? ElevatedButton( + onPressed: onPressed, + child: child, + ) + : FilledButton(onPressed: onPressed, child: child); + } +} + +class ImportantButtonWithIcon extends StatelessWidget { + const ImportantButtonWithIcon({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + }); + + final void Function()? onPressed; + final Widget icon; + final Widget label; + + @override + Widget build(BuildContext context) { + return yaru + ? ElevatedButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + ) + : FilledButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + ); + } +} + +class Progress extends StatelessWidget { + const Progress({ + super.key, + this.value, + this.backgroundColor, + this.color, + this.valueColor, + this.semanticsLabel, + this.semanticsValue, + this.strokeCap, + this.strokeWidth, + this.padding, + }); + + final double? value; + final Color? backgroundColor; + final Color? color; + final Animation? valueColor; + final double? strokeWidth; + final String? semanticsLabel; + final String? semanticsValue; + final StrokeCap? strokeCap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return yaru + ? YaruCircularProgressIndicator( + strokeWidth: strokeWidth, + value: value, + color: color, + trackColor: backgroundColor, + ) + : CircularProgressIndicator( + strokeWidth: strokeWidth ?? 4.0, + value: value, + color: color, + backgroundColor: value == null + ? null + : (backgroundColor ?? + context.theme.colorScheme.primary.withValues(alpha: 0.3)), + ); + } +} + +class LinearProgress extends StatelessWidget { + const LinearProgress({ + super.key, + this.color, + this.trackHeight, + this.value, + this.backgroundColor, + }); + + final double? value; + final Color? color, backgroundColor; + final double? trackHeight; + + @override + Widget build(BuildContext context) { + return yaru + ? YaruLinearProgressIndicator( + value: value, + strokeWidth: trackHeight, + color: color, + ) + : LinearProgressIndicator( + value: value, + minHeight: trackHeight, + color: color, + backgroundColor: backgroundColor, + borderRadius: BorderRadius.circular(2), + ); + } +} diff --git a/lib/common/view/confirm.dart b/lib/common/view/confirm.dart new file mode 100644 index 0000000..c09682d --- /dev/null +++ b/lib/common/view/confirm.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../l10n/l10n.dart'; +import 'space.dart'; +import 'ui_constants.dart'; + +class ConfirmationDialog extends StatelessWidget { + const ConfirmationDialog({ + super.key, + this.onConfirm, + this.onCancel, + this.additionalActions, + this.title, + this.content, + this.showCancel = true, + this.showCloseIcon = true, + }); + + final dynamic Function()? onConfirm; + final dynamic Function()? onCancel; + final List? additionalActions; + final Widget? title; + final Widget? content; + final bool showCancel; + final bool showCloseIcon; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: YaruDialogTitleBar( + title: title, + backgroundColor: Colors.transparent, + border: BorderSide.none, + isClosable: showCloseIcon, + ), + titlePadding: EdgeInsets.zero, + content: content, + actionsAlignment: MainAxisAlignment.start, + actionsOverflowAlignment: OverflowBarAlignment.center, + actionsPadding: const EdgeInsets.all(kMediumPadding), + actions: [ + Row( + children: space( + expand: true, + widthGap: kMediumPadding, + children: [ + ...?additionalActions, + if (showCancel) + OutlinedButton( + onPressed: () { + onCancel?.call(); + if (context.mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: () { + onConfirm?.call(); + + if (context.mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + child: Text( + l10n.ok, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/common/view/image_shimmer.dart b/lib/common/view/image_shimmer.dart new file mode 100644 index 0000000..a8f44a3 --- /dev/null +++ b/lib/common/view/image_shimmer.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:yaru/yaru.dart'; + +import 'build_context_x.dart'; + +class ImageShimmer extends StatelessWidget { + const ImageShimmer({super.key}); + + @override + Widget build(BuildContext context) { + final theme = context.theme; + + return Center( + child: Shimmer.fromColors( + baseColor: theme.cardColor, + highlightColor: theme.colorScheme.isLight + ? theme.cardColor.scale(lightness: -0.1) + : theme.cardColor.scale(lightness: 0.05), + child: Container( + color: theme.cardColor, + ), + ), + ); + } +} diff --git a/lib/common/view/safe_network_image.dart b/lib/common/view/safe_network_image.dart new file mode 100644 index 0000000..be7cd9b --- /dev/null +++ b/lib/common/view/safe_network_image.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_svg_image/cached_network_svg_image.dart'; +import 'package:file/file.dart' hide FileSystem; +import 'package:file/local.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path/path.dart' as p; +import 'package:xdg_directories/xdg_directories.dart'; +import 'package:yaru/yaru.dart'; + +class SafeNetworkImage extends StatelessWidget { + const SafeNetworkImage({ + super.key, + required this.url, + this.filterQuality = FilterQuality.medium, + this.fit = BoxFit.fitWidth, + this.fallBackIcon, + this.errorIcon, + this.height, + this.width, + required this.httpHeaders, + this.errorListener, + }); + + final String? url; + final FilterQuality filterQuality; + final BoxFit fit; + final Widget? fallBackIcon; + final Widget? errorIcon; + final double? height; + final double? width; + final Map httpHeaders; + final void Function(Object)? errorListener; + + @override + Widget build(BuildContext context) { + final fallBack = Center( + child: fallBackIcon ?? + Icon( + YaruIcons.user, + size: height != null ? height! * 0.7 : null, + ), + ); + + if (url == null) return fallBack; + + try { + if (url!.endsWith('.svg')) { + return CachedNetworkSVGImage( + url!, + fit: fit, + height: height, + width: width, + errorWidget: fallBack, + placeholder: fallBack, + ); + } + + return CachedNetworkImage( + httpHeaders: httpHeaders, + cacheManager: Platform.isLinux ? XdgCacheManager() : null, + imageUrl: url!, + imageBuilder: (context, imageProvider) => Image( + image: imageProvider, + filterQuality: filterQuality, + fit: fit, + height: height, + width: width, + ), + errorWidget: (context, url, _) => errorIcon ?? fallBack, + errorListener: errorListener ?? (error) {}, + ); + } on Exception { + return fallBack; + } + } +} + +// Code by @d-loose +class _XdgFileSystem implements FileSystem { + final Future _fileDir; + final String _cacheKey; + + _XdgFileSystem(this._cacheKey) : _fileDir = createDirectory(_cacheKey); + + static Future createDirectory(String key) async { + final baseDir = cacheHome; + final path = p.join(baseDir.path, key, 'images'); + + const fs = LocalFileSystem(); + final directory = fs.directory(path); + await directory.create(recursive: true); + return directory; + } + + @override + Future createFile(String name) async { + final directory = await _fileDir; + if (!(await directory.exists())) { + await createDirectory(_cacheKey); + } + return directory.childFile(name); + } +} + +class XdgCacheManager extends CacheManager with ImageCacheManager { + static final key = p.basename(Platform.resolvedExecutable); + + static final XdgCacheManager _instance = XdgCacheManager._(); + + factory XdgCacheManager() { + return _instance; + } + + XdgCacheManager._() : super(Config(key, fileSystem: _XdgFileSystem(key))); +} diff --git a/lib/common/view/scaffold_state_x.dart b/lib/common/view/scaffold_state_x.dart new file mode 100644 index 0000000..c1ac2b7 --- /dev/null +++ b/lib/common/view/scaffold_state_x.dart @@ -0,0 +1,8 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +extension ScaffoldStateX on ScaffoldState { + void showDrawer() => Platform.isMacOS ? openEndDrawer() : openDrawer(); + void hideDrawer() => Platform.isMacOS ? closeEndDrawer() : closeDrawer(); +} diff --git a/lib/common/view/sliver_sticky_panel.dart b/lib/common/view/sliver_sticky_panel.dart new file mode 100644 index 0000000..abd64f9 --- /dev/null +++ b/lib/common/view/sliver_sticky_panel.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'build_context_x.dart'; +import 'theme.dart'; + +class SliverStickyPanel extends StatelessWidget { + const SliverStickyPanel({ + super.key, + required this.child, + this.toolbarHeight = 48.0, + this.padding, + this.backgroundColor, + }); + + final Widget child; + final double toolbarHeight; + final EdgeInsetsGeometry? padding; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return SliverAppBar( + shape: const RoundedRectangleBorder(side: BorderSide.none), + elevation: 0, + backgroundColor: backgroundColor ?? getPanelBg(context.theme), + automaticallyImplyLeading: false, + pinned: true, + centerTitle: true, + titleSpacing: 0, + toolbarHeight: toolbarHeight, + actions: [Container()], + title: Padding( + padding: padding ?? + const EdgeInsets.only( + left: 15, + right: 15, + bottom: 10, + ), + child: child, + ), + ); + } +} diff --git a/lib/common/view/snackbars.dart b/lib/common/view/snackbars.dart new file mode 100644 index 0000000..75daa0e --- /dev/null +++ b/lib/common/view/snackbars.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../l10n/l10n.dart'; +import 'build_context_x.dart'; + +ScaffoldFeatureController? showSnackBar( + BuildContext context, { + required Widget content, +}) { + if (!context.mounted) return null; + return ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: content)); +} + +class CopyClipboardContent extends StatefulWidget { + const CopyClipboardContent({ + super.key, + required this.text, + this.onSearch, + this.showActions = true, + }); + + final String text; + final void Function()? onSearch; + final bool showActions; + + @override + State createState() => _CopyClipboardContentState(); +} + +class _CopyClipboardContentState extends State { + @override + void initState() { + super.initState(); + Clipboard.setData(ClipboardData(text: widget.text)); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final textColor = + theme.snackBarTheme.contentTextStyle?.color?.withValues(alpha: 0.8); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + Text( + context.l10n.copiedToClipboard, + style: TextStyle( + color: textColor, + ), + ), + Text( + widget.text, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/common/view/space.dart b/lib/common/view/space.dart new file mode 100644 index 0000000..a5af775 --- /dev/null +++ b/lib/common/view/space.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +List space({ + required Iterable children, + double? widthGap, + double? heightGap, + int skip = 1, + final bool expand = false, +}) => + children + .expand( + (item) sync* { + yield SizedBox( + width: widthGap, + height: heightGap, + ); + yield expand ? Expanded(child: item) : item; + }, + ) + .skip(skip) + .toList(); + +class AppBarSpace extends StatelessWidget implements PreferredSizeWidget { + const AppBarSpace({required this.size, super.key}); + + final Size size; + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize(size: size); + } + + @override + Size get preferredSize => size; +} diff --git a/lib/common/view/theme.dart b/lib/common/view/theme.dart new file mode 100644 index 0000000..ba6cd7b --- /dev/null +++ b/lib/common/view/theme.dart @@ -0,0 +1,79 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app_config.dart'; +import 'ui_constants.dart'; + +Color remixColor( + Color targetColor, { + List palette = Colors.accents, +}) { + double minDistance = double.infinity; + Color closestColor = palette[0]; + + for (Color color in palette) { + double distance = colorDistance(targetColor, color); + if (distance < minDistance) { + minDistance = distance; + closestColor = color; + } + } + + return closestColor; +} + +double colorDistance(Color color1, Color color2) { + int rDiff = color1.r.toInt() - color2.r.toInt(); + int gDiff = color1.g.toInt() - color2.g.toInt(); + int bDiff = color1.b.toInt() - (color2.b * 0.4).toInt(); + return sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); +} + +Color getTileColor( + bool isUserEvent, + ThemeData theme, +) => + isUserEvent + ? theme.colorScheme.primary.scale( + saturation: theme.colorScheme.isLight ? (yaru ? -0.3 : -0.6) : -0.6, + lightness: theme.colorScheme.isLight ? 0.65 : (yaru ? -0.5 : -0.7), + ) + : getMonochromeBg(theme: theme, factor: 6, darkFactor: yaru ? 12 : 15); + +Color getPanelBg(ThemeData theme) => + getMonochromeBg(theme: theme, darkFactor: 3); + +Color getMonochromeBg({ + required ThemeData theme, + double factor = 1.0, + double? darkFactor, + double? lightFactor, +}) => + theme.colorScheme.surface.scale( + lightness: (theme.colorScheme.isLight ? -0.02 : 0.005) * + (theme.colorScheme.isLight + ? lightFactor ?? factor + : darkFactor ?? factor), + ); + +Color getEventBadgeColor(ThemeData theme) => + theme.colorScheme.onSurface.withValues(alpha: 0.2); + +Color getEventBadgeTextColor(ThemeData theme) => theme.colorScheme.onSurface; + +EdgeInsets tilePadding(bool partOfMessageCohort) { + return partOfMessageCohort + ? const EdgeInsets.only( + left: kMediumPadding, + right: kMediumPadding, + bottom: kSmallPadding, + ) + : const EdgeInsets.only( + left: kMediumPadding, + right: kMediumPadding, + top: kMediumPadding, + bottom: kSmallPadding, + ); +} diff --git a/lib/common/view/ui_constants.dart b/lib/common/view/ui_constants.dart new file mode 100644 index 0000000..dfce699 --- /dev/null +++ b/lib/common/view/ui_constants.dart @@ -0,0 +1,33 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; +import 'package:window_manager/window_manager.dart'; + +const kTinyPadding = 2.5; + +const kSmallPadding = 5.0; + +const kMediumPadding = 10.0; + +const kBigPadding = 20.0; + +const kCardPadding = 15.0; + +const kSideBarWith = 250.0; + +const kShowSideBarThreshHold = 600.0; + +const kLoginFormWidth = 350.0; + +const Duration kAvatarAnimationDuration = Duration(milliseconds: 250); +const Curve kAvatarAnimationCurve = Curves.easeInOut; +const kTypingAvatarSize = 24.0; + +const kBubbleRadius = Radius.circular(4.0); +const kBigBubbleRadius = Radius.circular(8.0); + +const windowOptions = WindowOptions( + size: Size(1280, 1000), + minimumSize: Size(400, 600), + center: true, +); diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 0000000..830d6be --- /dev/null +++ b/lib/constants.dart @@ -0,0 +1,4 @@ +const kAppName = 'nebuchadnezzar'; +const kOrgName = 'org.feichtmeier'; +const kAppId = '$kAppName.$kOrgName'; +const kAppTitle = 'Chat'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..69e5310 --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,2788 @@ +{ + "@@locale": "de", + "alwaysUse24HourFormat": "false", + "@alwaysUse24HourFormat": { + "description": "Set to true to always display time of day in 24 hour format." + }, + "repeatPassword": "Repeat password", + "@repeatPassword": {}, + "notAnImage": "Not an image file.", + "@notAnImage": {}, + "remove": "Remove", + "@remove": { + "type": "text", + "placeholders": {} + }, + "importNow": "Import now", + "@importNow": {}, + "importEmojis": "Import Emojis", + "@importEmojis": {}, + "importFromZipFile": "Import from .zip file", + "@importFromZipFile": {}, + "exportEmotePack": "Export Emote pack as .zip", + "@exportEmotePack": {}, + "replace": "Replace", + "@replace": {}, + "about": "About", + "@about": { + "type": "text", + "placeholders": {} + }, + "accept": "Accept", + "@accept": { + "type": "text", + "placeholders": {} + }, + "acceptedTheInvitation": "👍 {username} accepted the invitation", + "@acceptedTheInvitation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "account": "Account", + "@account": { + "type": "text", + "placeholders": {} + }, + "activatedEndToEndEncryption": "🔐 {username} activated end to end encryption", + "@activatedEndToEndEncryption": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "addEmail": "Add email", + "@addEmail": { + "type": "text", + "placeholders": {} + }, + "confirmMatrixId": "Please confirm your Matrix ID in order to delete your account.", + "@confirmMatrixId": {}, + "supposedMxid": "This should be {mxid}", + "@supposedMxid": { + "type": "text", + "placeholders": { + "mxid": {} + } + }, + "addChatDescription": "Add a chat description...", + "@addChatDescription": {}, + "addToSpace": "Add to space", + "@addToSpace": {}, + "admin": "Admin", + "@admin": { + "type": "text", + "placeholders": {} + }, + "alias": "alias", + "@alias": { + "type": "text", + "placeholders": {} + }, + "all": "All", + "@all": { + "type": "text", + "placeholders": {} + }, + "allChats": "All chats", + "@allChats": { + "type": "text", + "placeholders": {} + }, + "commandHint_googly": "Send some googly eyes", + "@commandHint_googly": {}, + "commandHint_cuddle": "Send a cuddle", + "@commandHint_cuddle": {}, + "commandHint_hug": "Send a hug", + "@commandHint_hug": {}, + "googlyEyesContent": "{senderName} sends you googly eyes", + "@googlyEyesContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "cuddleContent": "{senderName} cuddles you", + "@cuddleContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "hugContent": "{senderName} hugs you", + "@hugContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "answeredTheCall": "{senderName} answered the call", + "@answeredTheCall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "anyoneCanJoin": "Anyone can join", + "@anyoneCanJoin": { + "type": "text", + "placeholders": {} + }, + "appLock": "App lock", + "@appLock": { + "type": "text", + "placeholders": {} + }, + "appLockDescription": "Lock the app when not using with a pin code", + "@appLockDescription": {}, + "archive": "Archive", + "@archive": { + "type": "text", + "placeholders": {} + }, + "areGuestsAllowedToJoin": "Are guest users allowed to join", + "@areGuestsAllowedToJoin": { + "type": "text", + "placeholders": {} + }, + "areYouSure": "Are you sure?", + "@areYouSure": { + "type": "text", + "placeholders": {} + }, + "areYouSureYouWantToLogout": "Are you sure you want to log out?", + "@areYouSureYouWantToLogout": { + "type": "text", + "placeholders": {} + }, + "askSSSSSign": "To be able to sign the other person, please enter your secure store passphrase or recovery key.", + "@askSSSSSign": { + "type": "text", + "placeholders": {} + }, + "askVerificationRequest": "Accept this verification request from {username}?", + "@askVerificationRequest": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "autoplayImages": "Automatically play animated stickers and emotes", + "@autoplayImages": { + "type": "text" + }, + "badServerLoginTypesException": "The homeserver supports the login types:\n{serverVersions}\nBut this app supports only:\n{supportedVersions}", + "@badServerLoginTypesException": { + "type": "text", + "placeholders": { + "serverVersions": {}, + "supportedVersions": {} + } + }, + "sendTypingNotifications": "Send typing notifications", + "@sendTypingNotifications": {}, + "swipeRightToLeftToReply": "Swipe right to left to reply", + "@swipeRightToLeftToReply": {}, + "sendOnEnter": "Send on enter", + "@sendOnEnter": {}, + "badServerVersionsException": "The homeserver supports the Spec versions:\n{serverVersions}\nBut this app supports only {supportedVersions}", + "@badServerVersionsException": { + "type": "text", + "placeholders": { + "serverVersions": {}, + "supportedVersions": {} + } + }, + "countChatsAndCountParticipants": "{chats} chats and {participants} participants", + "@countChatsAndCountParticipants": { + "type": "text", + "placeholders": { + "chats": {}, + "participants": {} + } + }, + "noMoreChatsFound": "No more chats found...", + "noChatsFoundHere": "No chats found here yet. Start a new chat with someone by using the button below. ⤵️", + "joinedChats": "Joined chats", + "unread": "Unread", + "space": "Space", + "spaces": "Spaces", + "banFromChat": "Ban from chat", + "@banFromChat": { + "type": "text", + "placeholders": {} + }, + "banned": "Banned", + "@banned": { + "type": "text", + "placeholders": {} + }, + "bannedUser": "{username} banned {targetName}", + "@bannedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "blockDevice": "Block Device", + "@blockDevice": { + "type": "text", + "placeholders": {} + }, + "blocked": "Blocked", + "@blocked": { + "type": "text", + "placeholders": {} + }, + "botMessages": "Bot messages", + "@botMessages": { + "type": "text", + "placeholders": {} + }, + "cancel": "Cancel", + "@cancel": { + "type": "text", + "placeholders": {} + }, + "cantOpenUri": "Can't open the URI {uri}", + "@cantOpenUri": { + "type": "text", + "placeholders": { + "uri": {} + } + }, + "changeDeviceName": "Change device name", + "@changeDeviceName": { + "type": "text", + "placeholders": {} + }, + "changedTheChatAvatar": "{username} changed the chat avatar", + "@changedTheChatAvatar": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheChatDescriptionTo": "{username} changed the chat description to: '{description}'", + "@changedTheChatDescriptionTo": { + "type": "text", + "placeholders": { + "username": {}, + "description": {} + } + }, + "changedTheChatNameTo": "{username} changed the chat name to: '{chatname}'", + "@changedTheChatNameTo": { + "type": "text", + "placeholders": { + "username": {}, + "chatname": {} + } + }, + "changedTheChatPermissions": "{username} changed the chat permissions", + "@changedTheChatPermissions": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheDisplaynameTo": "{username} changed their displayname to: '{displayname}'", + "@changedTheDisplaynameTo": { + "type": "text", + "placeholders": { + "username": {}, + "displayname": {} + } + }, + "changedTheGuestAccessRules": "{username} changed the guest access rules", + "@changedTheGuestAccessRules": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheGuestAccessRulesTo": "{username} changed the guest access rules to: {rules}", + "@changedTheGuestAccessRulesTo": { + "type": "text", + "placeholders": { + "username": {}, + "rules": {} + } + }, + "changedTheHistoryVisibility": "{username} changed the history visibility", + "@changedTheHistoryVisibility": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheHistoryVisibilityTo": "{username} changed the history visibility to: {rules}", + "@changedTheHistoryVisibilityTo": { + "type": "text", + "placeholders": { + "username": {}, + "rules": {} + } + }, + "changedTheJoinRules": "{username} changed the join rules", + "@changedTheJoinRules": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheJoinRulesTo": "{username} changed the join rules to: {joinRules}", + "@changedTheJoinRulesTo": { + "type": "text", + "placeholders": { + "username": {}, + "joinRules": {} + } + }, + "changedTheProfileAvatar": "{username} changed their avatar", + "@changedTheProfileAvatar": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheRoomAliases": "{username} changed the room aliases", + "@changedTheRoomAliases": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheRoomInvitationLink": "{username} changed the invitation link", + "@changedTheRoomInvitationLink": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changePassword": "Change password", + "@changePassword": { + "type": "text", + "placeholders": {} + }, + "changeTheHomeserver": "Change the homeserver", + "@changeTheHomeserver": { + "type": "text", + "placeholders": {} + }, + "changeTheme": "Change your style", + "@changeTheme": { + "type": "text", + "placeholders": {} + }, + "changeTheNameOfTheGroup": "Change the name of the group", + "@changeTheNameOfTheGroup": { + "type": "text", + "placeholders": {} + }, + "changeYourAvatar": "Change your avatar", + "@changeYourAvatar": { + "type": "text", + "placeholders": {} + }, + "channelCorruptedDecryptError": "The encryption has been corrupted", + "@channelCorruptedDecryptError": { + "type": "text", + "placeholders": {} + }, + "chat": "Chat", + "@chat": { + "type": "text", + "placeholders": {} + }, + "yourChatBackupHasBeenSetUp": "Your chat backup has been set up.", + "@yourChatBackupHasBeenSetUp": {}, + "chatBackup": "Chat backup", + "@chatBackup": { + "type": "text", + "placeholders": {} + }, + "chatBackupDescription": "Your old messages are secured with a recovery key. Please make sure you don't lose it.", + "@chatBackupDescription": { + "type": "text", + "placeholders": {} + }, + "chatDetails": "Chat details", + "@chatDetails": { + "type": "text", + "placeholders": {} + }, + "chatHasBeenAddedToThisSpace": "Chat has been added to this space", + "@chatHasBeenAddedToThisSpace": {}, + "chats": "Chats", + "@chats": { + "type": "text", + "placeholders": {} + }, + "chooseAStrongPassword": "Choose a strong password", + "@chooseAStrongPassword": { + "type": "text", + "placeholders": {} + }, + "clearArchive": "Clear archive", + "@clearArchive": {}, + "close": "Close", + "@close": { + "type": "text", + "placeholders": {} + }, + "commandHint_markasdm": "Mark as direct message room for the giving Matrix ID", + "@commandHint_markasdm": {}, + "commandHint_markasgroup": "Mark as group", + "@commandHint_markasgroup": {}, + "commandHint_ban": "Ban the given user from this room", + "@commandHint_ban": { + "type": "text", + "description": "Usage hint for the command /ban" + }, + "commandHint_clearcache": "Clear cache", + "@commandHint_clearcache": { + "type": "text", + "description": "Usage hint for the command /clearcache" + }, + "commandHint_create": "Create an empty group chat\nUse --no-encryption to disable encryption", + "@commandHint_create": { + "type": "text", + "description": "Usage hint for the command /create" + }, + "commandHint_discardsession": "Discard session", + "@commandHint_discardsession": { + "type": "text", + "description": "Usage hint for the command /discardsession" + }, + "commandHint_dm": "Start a direct chat\nUse --no-encryption to disable encryption", + "@commandHint_dm": { + "type": "text", + "description": "Usage hint for the command /dm" + }, + "commandHint_html": "Send HTML-formatted text", + "@commandHint_html": { + "type": "text", + "description": "Usage hint for the command /html" + }, + "commandHint_invite": "Invite the given user to this room", + "@commandHint_invite": { + "type": "text", + "description": "Usage hint for the command /invite" + }, + "commandHint_join": "Join the given room", + "@commandHint_join": { + "type": "text", + "description": "Usage hint for the command /join" + }, + "commandHint_kick": "Remove the given user from this room", + "@commandHint_kick": { + "type": "text", + "description": "Usage hint for the command /kick" + }, + "commandHint_leave": "Leave this room", + "@commandHint_leave": { + "type": "text", + "description": "Usage hint for the command /leave" + }, + "commandHint_me": "Describe yourself", + "@commandHint_me": { + "type": "text", + "description": "Usage hint for the command /me" + }, + "commandHint_myroomavatar": "Set your picture for this room (by mxc-uri)", + "@commandHint_myroomavatar": { + "type": "text", + "description": "Usage hint for the command /myroomavatar" + }, + "commandHint_myroomnick": "Set your display name for this room", + "@commandHint_myroomnick": { + "type": "text", + "description": "Usage hint for the command /myroomnick" + }, + "commandHint_op": "Set the given user's power level (default: 50)", + "@commandHint_op": { + "type": "text", + "description": "Usage hint for the command /op" + }, + "commandHint_plain": "Send unformatted text", + "@commandHint_plain": { + "type": "text", + "description": "Usage hint for the command /plain" + }, + "commandHint_react": "Send reply as a reaction", + "@commandHint_react": { + "type": "text", + "description": "Usage hint for the command /react" + }, + "commandHint_send": "Send text", + "@commandHint_send": { + "type": "text", + "description": "Usage hint for the command /send" + }, + "commandHint_unban": "Unban the given user from this room", + "@commandHint_unban": { + "type": "text", + "description": "Usage hint for the command /unban" + }, + "commandInvalid": "Command invalid", + "@commandInvalid": { + "type": "text" + }, + "commandMissing": "{command} is not a command.", + "@commandMissing": { + "type": "text", + "placeholders": { + "command": {} + }, + "description": "State that {command} is not a valid /command." + }, + "compareEmojiMatch": "Please compare the emojis", + "@compareEmojiMatch": { + "type": "text", + "placeholders": {} + }, + "compareNumbersMatch": "Please compare the numbers", + "@compareNumbersMatch": { + "type": "text", + "placeholders": {} + }, + "configureChat": "Configure chat", + "@configureChat": { + "type": "text", + "placeholders": {} + }, + "confirm": "Confirm", + "@confirm": { + "type": "text", + "placeholders": {} + }, + "connect": "Connect", + "@connect": { + "type": "text", + "placeholders": {} + }, + "contactHasBeenInvitedToTheGroup": "Contact has been invited to the group", + "@contactHasBeenInvitedToTheGroup": { + "type": "text", + "placeholders": {} + }, + "containsDisplayName": "Contains display name", + "@containsDisplayName": { + "type": "text", + "placeholders": {} + }, + "containsUserName": "Contains username", + "@containsUserName": { + "type": "text", + "placeholders": {} + }, + "contentHasBeenReported": "The content has been reported to the server admins", + "@contentHasBeenReported": { + "type": "text", + "placeholders": {} + }, + "copiedToClipboard": "Copied to clipboard", + "@copiedToClipboard": { + "type": "text", + "placeholders": {} + }, + "copy": "Copy", + "@copy": { + "type": "text", + "placeholders": {} + }, + "copyToClipboard": "Copy to clipboard", + "@copyToClipboard": { + "type": "text", + "placeholders": {} + }, + "couldNotDecryptMessage": "Could not decrypt message: {error}", + "@couldNotDecryptMessage": { + "type": "text", + "placeholders": { + "error": {} + } + }, + "countParticipants": "{count} participants", + "@countParticipants": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "create": "Create", + "@create": { + "type": "text", + "placeholders": {} + }, + "createdTheChat": "💬 {username} created the chat", + "@createdTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "createGroup": "Create group", + "@createGroup": {}, + "createNewSpace": "New space", + "@createNewSpace": { + "type": "text", + "placeholders": {} + }, + "currentlyActive": "Currently active", + "@currentlyActive": { + "type": "text", + "placeholders": {} + }, + "darkTheme": "Dark", + "@darkTheme": { + "type": "text", + "placeholders": {} + }, + "dateAndTimeOfDay": "{date}, {timeOfDay}", + "@dateAndTimeOfDay": { + "type": "text", + "placeholders": { + "date": {}, + "timeOfDay": {} + } + }, + "dateWithoutYear": "{month}-{day}", + "@dateWithoutYear": { + "type": "text", + "placeholders": { + "month": {}, + "day": {} + } + }, + "dateWithYear": "{year}-{month}-{day}", + "@dateWithYear": { + "type": "text", + "placeholders": { + "year": {}, + "month": {}, + "day": {} + } + }, + "deactivateAccountWarning": "This will deactivate your user account. This can not be undone! Are you sure?", + "@deactivateAccountWarning": { + "type": "text", + "placeholders": {} + }, + "defaultPermissionLevel": "Default permission level for new users", + "@defaultPermissionLevel": { + "type": "text", + "placeholders": {} + }, + "delete": "Delete", + "@delete": { + "type": "text", + "placeholders": {} + }, + "deleteAccount": "Delete account", + "@deleteAccount": { + "type": "text", + "placeholders": {} + }, + "deleteMessage": "Delete message", + "@deleteMessage": { + "type": "text", + "placeholders": {} + }, + "device": "Device", + "@device": { + "type": "text", + "placeholders": {} + }, + "deviceId": "Device ID", + "@deviceId": { + "type": "text", + "placeholders": {} + }, + "devices": "Devices", + "@devices": { + "type": "text", + "placeholders": {} + }, + "directChats": "Direct Chats", + "@directChats": { + "type": "text", + "placeholders": {} + }, + "allRooms": "All Group Chats", + "@allRooms": { + "type": "text", + "placeholders": {} + }, + "displaynameHasBeenChanged": "Displayname has been changed", + "@displaynameHasBeenChanged": { + "type": "text", + "placeholders": {} + }, + "downloadFile": "Download file", + "@downloadFile": { + "type": "text", + "placeholders": {} + }, + "edit": "Edit", + "@edit": { + "type": "text", + "placeholders": {} + }, + "editBlockedServers": "Edit blocked servers", + "@editBlockedServers": { + "type": "text", + "placeholders": {} + }, + "chatPermissions": "Chat permissions", + "@chatPermissions": {}, + "editDisplayname": "Edit displayname", + "@editDisplayname": { + "type": "text", + "placeholders": {} + }, + "editRoomAliases": "Edit room aliases", + "@editRoomAliases": { + "type": "text", + "placeholders": {} + }, + "editRoomAvatar": "Edit room avatar", + "@editRoomAvatar": { + "type": "text", + "placeholders": {} + }, + "emoteExists": "Emote already exists!", + "@emoteExists": { + "type": "text", + "placeholders": {} + }, + "emoteInvalid": "Invalid emote shortcode!", + "@emoteInvalid": { + "type": "text", + "placeholders": {} + }, + "emoteKeyboardNoRecents": "Recently-used emotes will appear here...", + "@emoteKeyboardNoRecents": { + "type": "text", + "placeholders": {} + }, + "emotePacks": "Emote packs for room", + "@emotePacks": { + "type": "text", + "placeholders": {} + }, + "emoteSettings": "Emote Settings", + "@emoteSettings": { + "type": "text", + "placeholders": {} + }, + "globalChatId": "Global chat ID", + "@globalChatId": {}, + "accessAndVisibility": "Access and visibility", + "@accessAndVisibility": {}, + "accessAndVisibilityDescription": "Who is allowed to join this chat and how the chat can be discovered.", + "@accessAndVisibilityDescription": {}, + "calls": "Calls", + "@calls": {}, + "customEmojisAndStickers": "Custom emojis and stickers", + "@customEmojisAndStickers": {}, + "customEmojisAndStickersBody": "Add or share custom emojis or stickers which can be used in any chat.", + "@customEmojisAndStickersBody": {}, + "emoteShortcode": "Emote shortcode", + "@emoteShortcode": { + "type": "text", + "placeholders": {} + }, + "emoteWarnNeedToPick": "You need to pick an emote shortcode and an image!", + "@emoteWarnNeedToPick": { + "type": "text", + "placeholders": {} + }, + "emptyChat": "Empty chat", + "@emptyChat": { + "type": "text", + "placeholders": {} + }, + "enableEmotesGlobally": "Enable emote pack globally", + "@enableEmotesGlobally": { + "type": "text", + "placeholders": {} + }, + "enableEncryption": "Enable encryption", + "@enableEncryption": { + "type": "text", + "placeholders": {} + }, + "enableEncryptionWarning": "You won't be able to disable the encryption anymore. Are you sure?", + "@enableEncryptionWarning": { + "type": "text", + "placeholders": {} + }, + "encrypted": "Encrypted", + "@encrypted": { + "type": "text", + "placeholders": {} + }, + "encryption": "Encryption", + "@encryption": { + "type": "text", + "placeholders": {} + }, + "encryptionNotEnabled": "Encryption is not enabled", + "@encryptionNotEnabled": { + "type": "text", + "placeholders": {} + }, + "endedTheCall": "{senderName} ended the call", + "@endedTheCall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "enterAnEmailAddress": "Enter an email address", + "@enterAnEmailAddress": { + "type": "text", + "placeholders": {} + }, + "homeserver": "Homeserver", + "@homeserver": {}, + "enterYourHomeserver": "Enter your homeserver", + "@enterYourHomeserver": { + "type": "text", + "placeholders": {} + }, + "errorObtainingLocation": "Error obtaining location: {error}", + "@errorObtainingLocation": { + "type": "text", + "placeholders": { + "error": {} + } + }, + "everythingReady": "Everything ready!", + "@everythingReady": { + "type": "text", + "placeholders": {} + }, + "extremeOffensive": "Extremely offensive", + "@extremeOffensive": { + "type": "text", + "placeholders": {} + }, + "fileName": "File name", + "@fileName": { + "type": "text", + "placeholders": {} + }, + "fluffychat": "FluffyChat", + "@fluffychat": { + "type": "text", + "placeholders": {} + }, + "fontSize": "Font size", + "@fontSize": { + "type": "text", + "placeholders": {} + }, + "forward": "Forward", + "@forward": { + "type": "text", + "placeholders": {} + }, + "fromJoining": "From joining", + "@fromJoining": { + "type": "text", + "placeholders": {} + }, + "fromTheInvitation": "From the invitation", + "@fromTheInvitation": { + "type": "text", + "placeholders": {} + }, + "goToTheNewRoom": "Go to the new room", + "@goToTheNewRoom": { + "type": "text", + "placeholders": {} + }, + "group": "Group", + "@group": { + "type": "text", + "placeholders": {} + }, + "chatDescription": "Chat description", + "@chatDescription": {}, + "chatDescriptionHasBeenChanged": "Chat description changed", + "@chatDescriptionHasBeenChanged": {}, + "groupIsPublic": "Group is public", + "@groupIsPublic": { + "type": "text", + "placeholders": {} + }, + "groups": "Groups", + "@groups": { + "type": "text", + "placeholders": {} + }, + "groupWith": "Group with {displayname}", + "@groupWith": { + "type": "text", + "placeholders": { + "displayname": {} + } + }, + "guestsAreForbidden": "Guests are forbidden", + "@guestsAreForbidden": { + "type": "text", + "placeholders": {} + }, + "guestsCanJoin": "Guests can join", + "@guestsCanJoin": { + "type": "text", + "placeholders": {} + }, + "hasWithdrawnTheInvitationFor": "{username} has withdrawn the invitation for {targetName}", + "@hasWithdrawnTheInvitationFor": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "help": "Help", + "@help": { + "type": "text", + "placeholders": {} + }, + "hideRedactedEvents": "Hide redacted events", + "@hideRedactedEvents": { + "type": "text", + "placeholders": {} + }, + "hideRedactedMessages": "Hide redacted messages", + "@hideRedactedMessages": {}, + "hideRedactedMessagesBody": "If someone redacts a message, this message won't be visible in the chat anymore.", + "@hideRedactedMessagesBody": {}, + "hideInvalidOrUnknownMessageFormats": "Hide invalid or unknown message formats", + "@hideInvalidOrUnknownMessageFormats": {}, + "howOffensiveIsThisContent": "How offensive is this content?", + "@howOffensiveIsThisContent": { + "type": "text", + "placeholders": {} + }, + "id": "ID", + "@id": { + "type": "text", + "placeholders": {} + }, + "identity": "Identity", + "@identity": { + "type": "text", + "placeholders": {} + }, + "block": "Block", + "@block": {}, + "blockedUsers": "Blocked users", + "@blockedUsers": {}, + "blockListDescription": "You can block users who are disturbing you. You won't be able to receive any messages or room invites from the users on your personal block list.", + "@blockListDescription": {}, + "blockUsername": "Ignore username", + "@blockUsername": {}, + "iHaveClickedOnLink": "I have clicked on the link", + "@iHaveClickedOnLink": { + "type": "text", + "placeholders": {} + }, + "incorrectPassphraseOrKey": "Incorrect passphrase or recovery key", + "@incorrectPassphraseOrKey": { + "type": "text", + "placeholders": {} + }, + "inoffensive": "Inoffensive", + "@inoffensive": { + "type": "text", + "placeholders": {} + }, + "inviteContact": "Invite contact", + "@inviteContact": { + "type": "text", + "placeholders": {} + }, + "inviteContactToGroupQuestion": "Do you want to invite {contact} to the chat \"{groupName}\"?", + "@inviteContactToGroupQuestion": {}, + "inviteContactToGroup": "Invite contact to {groupName}", + "@inviteContactToGroup": { + "type": "text", + "placeholders": { + "groupName": {} + } + }, + "noChatDescriptionYet": "No chat description created yet.", + "@noChatDescriptionYet": {}, + "tryAgain": "Try again", + "@tryAgain": {}, + "invalidServerName": "Invalid server name", + "@invalidServerName": {}, + "invited": "Invited", + "@invited": { + "type": "text", + "placeholders": {} + }, + "redactMessageDescription": "The message will be redacted for all participants in this conversation. This cannot be undone.", + "@redactMessageDescription": {}, + "optionalRedactReason": "(Optional) Reason for redacting this message...", + "@optionalRedactReason": {}, + "invitedUser": "📩 {username} invited {targetName}", + "@invitedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "invitedUsersOnly": "Invited users only", + "@invitedUsersOnly": { + "type": "text", + "placeholders": {} + }, + "inviteForMe": "Invite for me", + "@inviteForMe": { + "type": "text", + "placeholders": {} + }, + "inviteText": "{username} invited you to FluffyChat.\n1. Visit fluffychat.im and install the app \n2. Sign up or sign in \n3. Open the invite link: \n {link}", + "@inviteText": { + "type": "text", + "placeholders": { + "username": {}, + "link": {} + } + }, + "isTyping": "is typing…", + "@isTyping": { + "type": "text", + "placeholders": {} + }, + "joinedTheChat": "👋 {username} joined the chat", + "@joinedTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "joinRoom": "Join room", + "@joinRoom": { + "type": "text", + "placeholders": {} + }, + "kicked": "👞 {username} kicked {targetName}", + "@kicked": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "kickedAndBanned": "🙅 {username} kicked and banned {targetName}", + "@kickedAndBanned": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "kickFromChat": "Kick from chat", + "@kickFromChat": { + "type": "text", + "placeholders": {} + }, + "lastActiveAgo": "Last active: {localizedTimeShort}", + "@lastActiveAgo": { + "type": "text", + "placeholders": { + "localizedTimeShort": {} + } + }, + "leave": "Leave", + "@leave": { + "type": "text", + "placeholders": {} + }, + "leftTheChat": "Left the chat", + "@leftTheChat": { + "type": "text", + "placeholders": {} + }, + "license": "License", + "@license": { + "type": "text", + "placeholders": {} + }, + "lightTheme": "Light", + "@lightTheme": { + "type": "text", + "placeholders": {} + }, + "loadCountMoreParticipants": "Load {count} more participants", + "@loadCountMoreParticipants": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "dehydrate": "Export session and wipe device", + "@dehydrate": {}, + "dehydrateWarning": "This action cannot be undone. Ensure you safely store the backup file.", + "@dehydrateWarning": {}, + "dehydrateTor": "TOR Users: Export session", + "@dehydrateTor": {}, + "dehydrateTorLong": "For TOR users, it is recommended to export the session before closing the window.", + "@dehydrateTorLong": {}, + "hydrateTor": "TOR Users: Import session export", + "@hydrateTor": {}, + "hydrateTorLong": "Did you export your session last time on TOR? Quickly import it and continue chatting.", + "@hydrateTorLong": {}, + "hydrate": "Restore from backup file", + "@hydrate": {}, + "loadingPleaseWait": "Loading… Please wait.", + "@loadingPleaseWait": { + "type": "text", + "placeholders": {} + }, + "loadMore": "Load more…", + "@loadMore": { + "type": "text", + "placeholders": {} + }, + "locationDisabledNotice": "Location services are disabled. Please enable them to be able to share your location.", + "@locationDisabledNotice": { + "type": "text", + "placeholders": {} + }, + "locationPermissionDeniedNotice": "Location permission denied. Please grant them to be able to share your location.", + "@locationPermissionDeniedNotice": { + "type": "text", + "placeholders": {} + }, + "login": "Login", + "@login": { + "type": "text", + "placeholders": {} + }, + "logInTo": "Log in to {homeserver}", + "@logInTo": { + "type": "text", + "placeholders": { + "homeserver": {} + } + }, + "logout": "Logout", + "@logout": { + "type": "text", + "placeholders": {} + }, + "memberChanges": "Member changes", + "@memberChanges": { + "type": "text", + "placeholders": {} + }, + "mention": "Mention", + "@mention": { + "type": "text", + "placeholders": {} + }, + "messages": "Messages", + "@messages": { + "type": "text", + "placeholders": {} + }, + "messagesStyle": "Messages:", + "@messagesStyle": {}, + "moderator": "Moderator", + "@moderator": { + "type": "text", + "placeholders": {} + }, + "muteChat": "Mute chat", + "@muteChat": { + "type": "text", + "placeholders": {} + }, + "needPantalaimonWarning": "Please be aware that you need Pantalaimon to use end-to-end encryption for now.", + "@needPantalaimonWarning": { + "type": "text", + "placeholders": {} + }, + "newChat": "New chat", + "@newChat": { + "type": "text", + "placeholders": {} + }, + "newMessageInFluffyChat": "💬 New message in FluffyChat", + "@newMessageInFluffyChat": { + "type": "text", + "placeholders": {} + }, + "newVerificationRequest": "New verification request!", + "@newVerificationRequest": { + "type": "text", + "placeholders": {} + }, + "next": "Next", + "@next": { + "type": "text", + "placeholders": {} + }, + "no": "No", + "@no": { + "type": "text", + "placeholders": {} + }, + "noConnectionToTheServer": "No connection to the server", + "@noConnectionToTheServer": { + "type": "text", + "placeholders": {} + }, + "noEmotesFound": "No emotes found. 😕", + "@noEmotesFound": { + "type": "text", + "placeholders": {} + }, + "noEncryptionForPublicRooms": "You can only activate encryption as soon as the room is no longer publicly accessible.", + "@noEncryptionForPublicRooms": { + "type": "text", + "placeholders": {} + }, + "noGoogleServicesWarning": "Firebase Cloud Messaging doesn't appear to be available on your device. To still receive push notifications, we recommend installing ntfy. With ntfy or another Unified Push provider you can receive push notifications in a data secure way. You can download ntfy from the PlayStore or from F-Droid.", + "@noGoogleServicesWarning": { + "type": "text", + "placeholders": {} + }, + "noMatrixServer": "{server1} is no matrix server, use {server2} instead?", + "@noMatrixServer": { + "type": "text", + "placeholders": { + "server1": {}, + "server2": {} + } + }, + "shareInviteLink": "Share invite link", + "@shareInviteLink": {}, + "scanQrCode": "Scan QR code", + "@scanQrCode": {}, + "none": "None", + "@none": { + "type": "text", + "placeholders": {} + }, + "noPasswordRecoveryDescription": "You have not added a way to recover your password yet.", + "@noPasswordRecoveryDescription": { + "type": "text", + "placeholders": {} + }, + "noPermission": "No permission", + "@noPermission": { + "type": "text", + "placeholders": {} + }, + "noRoomsFound": "No rooms found…", + "@noRoomsFound": { + "type": "text", + "placeholders": {} + }, + "notifications": "Notifications", + "@notifications": { + "type": "text", + "placeholders": {} + }, + "notificationsEnabledForThisAccount": "Notifications enabled for this account", + "@notificationsEnabledForThisAccount": { + "type": "text", + "placeholders": {} + }, + "numUsersTyping": "{count} users are typing…", + "@numUsersTyping": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "obtainingLocation": "Obtaining location…", + "@obtainingLocation": { + "type": "text", + "placeholders": {} + }, + "offensive": "Offensive", + "@offensive": { + "type": "text", + "placeholders": {} + }, + "offline": "Offline", + "@offline": { + "type": "text", + "placeholders": {} + }, + "ok": "Ok", + "@ok": { + "type": "text", + "placeholders": {} + }, + "online": "Online", + "@online": { + "type": "text", + "placeholders": {} + }, + "onlineKeyBackupEnabled": "Online Key Backup is enabled", + "@onlineKeyBackupEnabled": { + "type": "text", + "placeholders": {} + }, + "oopsPushError": "Oops! Unfortunately, an error occurred when setting up the push notifications.", + "@oopsPushError": { + "type": "text", + "placeholders": {} + }, + "oopsSomethingWentWrong": "Oops, something went wrong…", + "@oopsSomethingWentWrong": { + "type": "text", + "placeholders": {} + }, + "openAppToReadMessages": "Open app to read messages", + "@openAppToReadMessages": { + "type": "text", + "placeholders": {} + }, + "openCamera": "Open camera", + "@openCamera": { + "type": "text", + "placeholders": {} + }, + "openVideoCamera": "Open camera for a video", + "@openVideoCamera": { + "type": "text", + "placeholders": {} + }, + "oneClientLoggedOut": "One of your clients has been logged out", + "@oneClientLoggedOut": {}, + "addAccount": "Add account", + "@addAccount": {}, + "editBundlesForAccount": "Edit bundles for this account", + "@editBundlesForAccount": {}, + "addToBundle": "Add to bundle", + "@addToBundle": {}, + "removeFromBundle": "Remove from this bundle", + "@removeFromBundle": {}, + "bundleName": "Bundle name", + "@bundleName": {}, + "enableMultiAccounts": "(BETA) Enable multi accounts on this device", + "@enableMultiAccounts": {}, + "openInMaps": "Open in maps", + "@openInMaps": { + "type": "text", + "placeholders": {} + }, + "link": "Link", + "@link": {}, + "serverRequiresEmail": "This server needs to validate your email address for registration.", + "@serverRequiresEmail": {}, + "or": "Or", + "@or": { + "type": "text", + "placeholders": {} + }, + "participant": "Participant", + "@participant": { + "type": "text", + "placeholders": {} + }, + "passphraseOrKey": "passphrase or recovery key", + "@passphraseOrKey": { + "type": "text", + "placeholders": {} + }, + "password": "Password", + "@password": { + "type": "text", + "placeholders": {} + }, + "passwordForgotten": "Password forgotten", + "@passwordForgotten": { + "type": "text", + "placeholders": {} + }, + "passwordHasBeenChanged": "Password has been changed", + "@passwordHasBeenChanged": { + "type": "text", + "placeholders": {} + }, + "hideMemberChangesInPublicChats": "Hide member changes in public chats", + "@hideMemberChangesInPublicChats": {}, + "hideMemberChangesInPublicChatsBody": "Do not show in the chat timeline if someone joins or leaves a public chat to improve readability.", + "@hideMemberChangesInPublicChatsBody": {}, + "overview": "Overview", + "@overview": {}, + "notifyMeFor": "Notify me for", + "@notifyMeFor": {}, + "passwordRecoverySettings": "Password recovery settings", + "@passwordRecoverySettings": {}, + "passwordRecovery": "Password recovery", + "@passwordRecovery": { + "type": "text", + "placeholders": {} + }, + "people": "People", + "@people": { + "type": "text", + "placeholders": {} + }, + "pickImage": "Pick an image", + "@pickImage": { + "type": "text", + "placeholders": {} + }, + "pin": "Pin", + "@pin": { + "type": "text", + "placeholders": {} + }, + "play": "Play {fileName}", + "@play": { + "type": "text", + "placeholders": { + "fileName": {} + } + }, + "pleaseChoose": "Please choose", + "@pleaseChoose": { + "type": "text", + "placeholders": {} + }, + "pleaseChooseAPasscode": "Please choose a pass code", + "@pleaseChooseAPasscode": { + "type": "text", + "placeholders": {} + }, + "pleaseClickOnLink": "Please click on the link in the email and then proceed.", + "@pleaseClickOnLink": { + "type": "text", + "placeholders": {} + }, + "pleaseEnter4Digits": "Please enter 4 digits or leave empty to disable app lock.", + "@pleaseEnter4Digits": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterRecoveryKey": "Please enter your recovery key:", + "@pleaseEnterRecoveryKey": {}, + "pleaseEnterYourPassword": "Please enter your password", + "@pleaseEnterYourPassword": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterYourPin": "Please enter your pin", + "@pleaseEnterYourPin": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterYourUsername": "Please enter your username", + "@pleaseEnterYourUsername": { + "type": "text", + "placeholders": {} + }, + "pleaseFollowInstructionsOnWeb": "Please follow the instructions on the website and tap on next.", + "@pleaseFollowInstructionsOnWeb": { + "type": "text", + "placeholders": {} + }, + "privacy": "Privacy", + "@privacy": { + "type": "text", + "placeholders": {} + }, + "publicRooms": "Public Rooms", + "@publicRooms": { + "type": "text", + "placeholders": {} + }, + "pushRules": "Push rules", + "@pushRules": { + "type": "text", + "placeholders": {} + }, + "reason": "Reason", + "@reason": { + "type": "text", + "placeholders": {} + }, + "recording": "Recording", + "@recording": { + "type": "text", + "placeholders": {} + }, + "redactedBy": "Redacted by {username}", + "@redactedBy": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "directChat": "Direct chat", + "@directChat": {}, + "redactedByBecause": "Redacted by {username} because: \"{reason}\"", + "@redactedByBecause": { + "type": "text", + "placeholders": { + "username": {}, + "reason": {} + } + }, + "redactedAnEvent": "{username} redacted an event", + "@redactedAnEvent": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "redactMessage": "Redact message", + "@redactMessage": { + "type": "text", + "placeholders": {} + }, + "register": "Register", + "@register": { + "type": "text", + "placeholders": {} + }, + "reject": "Reject", + "@reject": { + "type": "text", + "placeholders": {} + }, + "rejectedTheInvitation": "{username} rejected the invitation", + "@rejectedTheInvitation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "rejoin": "Rejoin", + "@rejoin": { + "type": "text", + "placeholders": {} + }, + "removeAllOtherDevices": "Remove all other devices", + "@removeAllOtherDevices": { + "type": "text", + "placeholders": {} + }, + "removedBy": "Removed by {username}", + "@removedBy": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "removeDevice": "Remove device", + "@removeDevice": { + "type": "text", + "placeholders": {} + }, + "unbanFromChat": "Unban from chat", + "@unbanFromChat": { + "type": "text", + "placeholders": {} + }, + "removeYourAvatar": "Remove your avatar", + "@removeYourAvatar": { + "type": "text", + "placeholders": {} + }, + "replaceRoomWithNewerVersion": "Replace room with newer version", + "@replaceRoomWithNewerVersion": { + "type": "text", + "placeholders": {} + }, + "reply": "Reply", + "@reply": { + "type": "text", + "placeholders": {} + }, + "reportMessage": "Report message", + "@reportMessage": { + "type": "text", + "placeholders": {} + }, + "requestPermission": "Request permission", + "@requestPermission": { + "type": "text", + "placeholders": {} + }, + "roomHasBeenUpgraded": "Room has been upgraded", + "@roomHasBeenUpgraded": { + "type": "text", + "placeholders": {} + }, + "roomVersion": "Room version", + "@roomVersion": { + "type": "text", + "placeholders": {} + }, + "saveFile": "Save file", + "@saveFile": { + "type": "text", + "placeholders": {} + }, + "search": "Search", + "@search": { + "type": "text", + "placeholders": {} + }, + "security": "Security", + "@security": { + "type": "text", + "placeholders": {} + }, + "recoveryKey": "Recovery key", + "@recoveryKey": {}, + "recoveryKeyLost": "Recovery key lost?", + "@recoveryKeyLost": {}, + "seenByUser": "Seen by {username}", + "@seenByUser": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "send": "Send", + "@send": { + "type": "text", + "placeholders": {} + }, + "sendAMessage": "Send a message", + "@sendAMessage": { + "type": "text", + "placeholders": {} + }, + "sendAsText": "Send as text", + "@sendAsText": { + "type": "text" + }, + "sendAudio": "Send audio", + "@sendAudio": { + "type": "text", + "placeholders": {} + }, + "sendFile": "Send file", + "@sendFile": { + "type": "text", + "placeholders": {} + }, + "sendImage": "Send image", + "@sendImage": { + "type": "text", + "placeholders": {} + }, + "sendMessages": "Send messages", + "@sendMessages": { + "type": "text", + "placeholders": {} + }, + "sendOriginal": "Send original", + "@sendOriginal": { + "type": "text", + "placeholders": {} + }, + "sendSticker": "Send sticker", + "@sendSticker": { + "type": "text", + "placeholders": {} + }, + "sendVideo": "Send video", + "@sendVideo": { + "type": "text", + "placeholders": {} + }, + "sentAFile": "📁 {username} sent a file", + "@sentAFile": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAnAudio": "🎤 {username} sent an audio", + "@sentAnAudio": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAPicture": "🖼️ {username} sent a picture", + "@sentAPicture": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentASticker": "😊 {username} sent a sticker", + "@sentASticker": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAVideo": "🎥 {username} sent a video", + "@sentAVideo": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentCallInformations": "{senderName} sent call information", + "@sentCallInformations": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "separateChatTypes": "Separate Direct Chats and Groups", + "@separateChatTypes": { + "type": "text", + "placeholders": {} + }, + "setAsCanonicalAlias": "Set as main alias", + "@setAsCanonicalAlias": { + "type": "text", + "placeholders": {} + }, + "setCustomEmotes": "Set custom emotes", + "@setCustomEmotes": { + "type": "text", + "placeholders": {} + }, + "setChatDescription": "Set chat description", + "@setChatDescription": {}, + "setInvitationLink": "Set invitation link", + "@setInvitationLink": { + "type": "text", + "placeholders": {} + }, + "setPermissionsLevel": "Set permissions level", + "@setPermissionsLevel": { + "type": "text", + "placeholders": {} + }, + "setStatus": "Set status", + "@setStatus": { + "type": "text", + "placeholders": {} + }, + "settings": "Settings", + "@settings": { + "type": "text", + "placeholders": {} + }, + "share": "Share", + "@share": { + "type": "text", + "placeholders": {} + }, + "sharedTheLocation": "{username} shared their location", + "@sharedTheLocation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "shareLocation": "Share location", + "@shareLocation": { + "type": "text", + "placeholders": {} + }, + "showPassword": "Show password", + "@showPassword": { + "type": "text", + "placeholders": {} + }, + "presenceStyle": "Presence:", + "@presenceStyle": { + "type": "text", + "placeholders": {} + }, + "presencesToggle": "Show status messages from other users", + "@presencesToggle": { + "type": "text", + "placeholders": {} + }, + "singlesignon": "Single Sign on", + "@singlesignon": { + "type": "text", + "placeholders": {} + }, + "skip": "Skip", + "@skip": { + "type": "text", + "placeholders": {} + }, + "sourceCode": "Source code", + "@sourceCode": { + "type": "text", + "placeholders": {} + }, + "spaceIsPublic": "Space is public", + "@spaceIsPublic": { + "type": "text", + "placeholders": {} + }, + "spaceName": "Space name", + "@spaceName": { + "type": "text", + "placeholders": {} + }, + "startedACall": "{senderName} started a call", + "@startedACall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "startFirstChat": "Start your first chat", + "@startFirstChat": {}, + "status": "Status", + "@status": { + "type": "text", + "placeholders": {} + }, + "statusExampleMessage": "How are you today?", + "@statusExampleMessage": { + "type": "text", + "placeholders": {} + }, + "submit": "Submit", + "@submit": { + "type": "text", + "placeholders": {} + }, + "synchronizingPleaseWait": "Synchronizing… Please wait.", + "@synchronizingPleaseWait": { + "type": "text", + "placeholders": {} + }, + "systemTheme": "System", + "@systemTheme": { + "type": "text", + "placeholders": {} + }, + "theyDontMatch": "They Don't Match", + "@theyDontMatch": { + "type": "text", + "placeholders": {} + }, + "theyMatch": "They Match", + "@theyMatch": { + "type": "text", + "placeholders": {} + }, + "title": "FluffyChat", + "@title": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "toggleFavorite": "Toggle Favorite", + "@toggleFavorite": { + "type": "text", + "placeholders": {} + }, + "toggleMuted": "Toggle Muted", + "@toggleMuted": { + "type": "text", + "placeholders": {} + }, + "toggleUnread": "Mark Read/Unread", + "@toggleUnread": { + "type": "text", + "placeholders": {} + }, + "tooManyRequestsWarning": "Too many requests. Please try again later!", + "@tooManyRequestsWarning": { + "type": "text", + "placeholders": {} + }, + "transferFromAnotherDevice": "Transfer from another device", + "@transferFromAnotherDevice": { + "type": "text", + "placeholders": {} + }, + "tryToSendAgain": "Try to send again", + "@tryToSendAgain": { + "type": "text", + "placeholders": {} + }, + "unavailable": "Unavailable", + "@unavailable": { + "type": "text", + "placeholders": {} + }, + "unbannedUser": "{username} unbanned {targetName}", + "@unbannedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "unblockDevice": "Unblock Device", + "@unblockDevice": { + "type": "text", + "placeholders": {} + }, + "unknownDevice": "Unknown device", + "@unknownDevice": { + "type": "text", + "placeholders": {} + }, + "unknownEncryptionAlgorithm": "Unknown encryption algorithm", + "@unknownEncryptionAlgorithm": { + "type": "text", + "placeholders": {} + }, + "unknownEvent": "Unknown event '{type}'", + "@unknownEvent": { + "type": "text", + "placeholders": { + "type": {} + } + }, + "unmuteChat": "Unmute chat", + "@unmuteChat": { + "type": "text", + "placeholders": {} + }, + "unpin": "Unpin", + "@unpin": { + "type": "text", + "placeholders": {} + }, + "unreadChats": "{unreadCount, plural, =1{1 unread chat} other{{unreadCount} unread chats}}", + "@unreadChats": { + "type": "text", + "placeholders": { + "unreadCount": {} + } + }, + "userAndOthersAreTyping": "{username} and {count} others are typing…", + "@userAndOthersAreTyping": { + "type": "text", + "placeholders": { + "username": {}, + "count": {} + } + }, + "userAndUserAreTyping": "{username} and {username2} are typing…", + "@userAndUserAreTyping": { + "type": "text", + "placeholders": { + "username": {}, + "username2": {} + } + }, + "userIsTyping": "{username} is typing…", + "@userIsTyping": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "userLeftTheChat": "🚪 {username} left the chat", + "@userLeftTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "username": "Username", + "@username": { + "type": "text", + "placeholders": {} + }, + "userSentUnknownEvent": "{username} sent a {type} event", + "@userSentUnknownEvent": { + "type": "text", + "placeholders": { + "username": {}, + "type": {} + } + }, + "unverified": "Unverified", + "@unverified": {}, + "verified": "Verified", + "@verified": { + "type": "text", + "placeholders": {} + }, + "verify": "Verify", + "@verify": { + "type": "text", + "placeholders": {} + }, + "verifyStart": "Start Verification", + "@verifyStart": { + "type": "text", + "placeholders": {} + }, + "verifySuccess": "You successfully verified!", + "@verifySuccess": { + "type": "text", + "placeholders": {} + }, + "verifyTitle": "Verifying other account", + "@verifyTitle": { + "type": "text", + "placeholders": {} + }, + "videoCall": "Video call", + "@videoCall": { + "type": "text", + "placeholders": {} + }, + "visibilityOfTheChatHistory": "Visibility of the chat history", + "@visibilityOfTheChatHistory": { + "type": "text", + "placeholders": {} + }, + "visibleForAllParticipants": "Visible for all participants", + "@visibleForAllParticipants": { + "type": "text", + "placeholders": {} + }, + "visibleForEveryone": "Visible for everyone", + "@visibleForEveryone": { + "type": "text", + "placeholders": {} + }, + "voiceMessage": "Voice message", + "@voiceMessage": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerAcceptRequest": "Waiting for partner to accept the request…", + "@waitingPartnerAcceptRequest": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerEmoji": "Waiting for partner to accept the emoji…", + "@waitingPartnerEmoji": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerNumbers": "Waiting for partner to accept the numbers…", + "@waitingPartnerNumbers": { + "type": "text", + "placeholders": {} + }, + "wallpaper": "Wallpaper:", + "@wallpaper": { + "type": "text", + "placeholders": {} + }, + "warning": "Warning!", + "@warning": { + "type": "text", + "placeholders": {} + }, + "weSentYouAnEmail": "We sent you an email", + "@weSentYouAnEmail": { + "type": "text", + "placeholders": {} + }, + "whoCanPerformWhichAction": "Who can perform which action", + "@whoCanPerformWhichAction": { + "type": "text", + "placeholders": {} + }, + "whoIsAllowedToJoinThisGroup": "Who is allowed to join this group", + "@whoIsAllowedToJoinThisGroup": { + "type": "text", + "placeholders": {} + }, + "whyDoYouWantToReportThis": "Why do you want to report this?", + "@whyDoYouWantToReportThis": { + "type": "text", + "placeholders": {} + }, + "wipeChatBackup": "Wipe your chat backup to create a new recovery key?", + "@wipeChatBackup": { + "type": "text", + "placeholders": {} + }, + "withTheseAddressesRecoveryDescription": "With these addresses you can recover your password.", + "@withTheseAddressesRecoveryDescription": { + "type": "text", + "placeholders": {} + }, + "writeAMessage": "Write a message…", + "@writeAMessage": { + "type": "text", + "placeholders": {} + }, + "yes": "Yes", + "@yes": { + "type": "text", + "placeholders": {} + }, + "you": "You", + "@you": { + "type": "text", + "placeholders": {} + }, + "youAreNoLongerParticipatingInThisChat": "You are no longer participating in this chat", + "@youAreNoLongerParticipatingInThisChat": { + "type": "text", + "placeholders": {} + }, + "youHaveBeenBannedFromThisChat": "You have been banned from this chat", + "@youHaveBeenBannedFromThisChat": { + "type": "text", + "placeholders": {} + }, + "yourPublicKey": "Your public key", + "@yourPublicKey": { + "type": "text", + "placeholders": {} + }, + "messageInfo": "Message info", + "@messageInfo": {}, + "time": "Time", + "@time": {}, + "messageType": "Message Type", + "@messageType": {}, + "sender": "Sender", + "@sender": {}, + "openGallery": "Open gallery", + "@openGallery": {}, + "removeFromSpace": "Remove from space", + "@removeFromSpace": {}, + "addToSpaceDescription": "Select a space to add this chat to it.", + "@addToSpaceDescription": {}, + "start": "Start", + "@start": {}, + "pleaseEnterRecoveryKeyDescription": "To unlock your old messages, please enter your recovery key that has been generated in a previous session. Your recovery key is NOT your password.", + "@pleaseEnterRecoveryKeyDescription": {}, + "publish": "Publish", + "@publish": {}, + "videoWithSize": "Video ({size})", + "@videoWithSize": { + "type": "text", + "placeholders": { + "size": {} + } + }, + "openChat": "Open Chat", + "@openChat": {}, + "markAsRead": "Mark as read", + "@markAsRead": {}, + "reportUser": "Report user", + "@reportUser": {}, + "dismiss": "Dismiss", + "@dismiss": {}, + "reactedWith": "{sender} reacted with {reaction}", + "@reactedWith": { + "type": "text", + "placeholders": { + "sender": {}, + "reaction": {} + } + }, + "pinMessage": "Pin to room", + "@pinMessage": {}, + "confirmEventUnpin": "Are you sure to permanently unpin the event?", + "@confirmEventUnpin": {}, + "emojis": "Emojis", + "@emojis": {}, + "placeCall": "Place call", + "@placeCall": {}, + "voiceCall": "Voice call", + "@voiceCall": {}, + "unsupportedAndroidVersion": "Unsupported Android version", + "@unsupportedAndroidVersion": {}, + "unsupportedAndroidVersionLong": "This feature requires a newer Android version. Please check for updates or Lineage OS support.", + "@unsupportedAndroidVersionLong": {}, + "videoCallsBetaWarning": "Please note that video calls are currently in beta. They might not work as expected or work at all on all platforms.", + "@videoCallsBetaWarning": {}, + "experimentalVideoCalls": "Experimental video calls", + "@experimentalVideoCalls": {}, + "emailOrUsername": "Email or username", + "@emailOrUsername": {}, + "indexedDbErrorTitle": "Private mode issues", + "@indexedDbErrorTitle": {}, + "indexedDbErrorLong": "The message storage is unfortunately not enabled in private mode by default.\nPlease visit\n - about:config\n - set dom.indexedDB.privateBrowsing.enabled to true\nOtherwise, it is not possible to run FluffyChat.", + "@indexedDbErrorLong": {}, + "switchToAccount": "Switch to account {number}", + "@switchToAccount": { + "type": "text", + "placeholders": { + "number": {} + } + }, + "nextAccount": "Next account", + "@nextAccount": {}, + "previousAccount": "Previous account", + "@previousAccount": {}, + "addWidget": "Add widget", + "@addWidget": {}, + "widgetVideo": "Video", + "@widgetVideo": {}, + "widgetEtherpad": "Text note", + "@widgetEtherpad": {}, + "widgetJitsi": "Jitsi Meet", + "@widgetJitsi": {}, + "widgetCustom": "Custom", + "@widgetCustom": {}, + "widgetName": "Name", + "@widgetName": {}, + "widgetUrlError": "This is not a valid URL.", + "@widgetUrlError": {}, + "widgetNameError": "Please provide a display name.", + "@widgetNameError": {}, + "errorAddingWidget": "Error adding the widget.", + "@errorAddingWidget": {}, + "youRejectedTheInvitation": "You rejected the invitation", + "@youRejectedTheInvitation": {}, + "youJoinedTheChat": "You joined the chat", + "@youJoinedTheChat": {}, + "youAcceptedTheInvitation": "👍 You accepted the invitation", + "@youAcceptedTheInvitation": {}, + "youBannedUser": "You banned {user}", + "@youBannedUser": { + "placeholders": { + "user": {} + } + }, + "youHaveWithdrawnTheInvitationFor": "You have withdrawn the invitation for {user}", + "@youHaveWithdrawnTheInvitationFor": { + "placeholders": { + "user": {} + } + }, + "youInvitedToBy": "📩 You have been invited via link to:\n{alias}", + "@youInvitedToBy": { + "placeholders": { + "alias": {} + } + }, + "youInvitedBy": "📩 You have been invited by {user}", + "@youInvitedBy": { + "placeholders": { + "user": {} + } + }, + "invitedBy": "📩 Invited by {user}", + "@invitedBy": { + "placeholders": { + "user": {} + } + }, + "youInvitedUser": "📩 You invited {user}", + "@youInvitedUser": { + "placeholders": { + "user": {} + } + }, + "youKicked": "👞 You kicked {user}", + "@youKicked": { + "placeholders": { + "user": {} + } + }, + "youKickedAndBanned": "🙅 You kicked and banned {user}", + "@youKickedAndBanned": { + "placeholders": { + "user": {} + } + }, + "youUnbannedUser": "You unbanned {user}", + "@youUnbannedUser": { + "placeholders": { + "user": {} + } + }, + "hasKnocked": "🚪 {user} has knocked", + "@hasKnocked": { + "placeholders": { + "user": {} + } + }, + "usersMustKnock": "Users must knock", + "@usersMustKnock": {}, + "noOneCanJoin": "No one can join", + "@noOneCanJoin": {}, + "userWouldLikeToChangeTheChat": "{user} would like to join the chat.", + "@userWouldLikeToChangeTheChat": { + "placeholders": { + "user": {} + } + }, + "noPublicLinkHasBeenCreatedYet": "No public link has been created yet", + "@noPublicLinkHasBeenCreatedYet": {}, + "knock": "Knock", + "@knock": {}, + "users": "Users", + "@users": {}, + "unlockOldMessages": "Unlock old messages", + "@unlockOldMessages": {}, + "storeInSecureStorageDescription": "Store the recovery key in the secure storage of this device.", + "@storeInSecureStorageDescription": {}, + "saveKeyManuallyDescription": "Save this key manually by triggering the system share dialog or clipboard.", + "@saveKeyManuallyDescription": {}, + "storeInAndroidKeystore": "Store in Android KeyStore", + "@storeInAndroidKeystore": {}, + "storeInAppleKeyChain": "Store in Apple KeyChain", + "@storeInAppleKeyChain": {}, + "storeSecurlyOnThisDevice": "Store securely on this device", + "@storeSecurlyOnThisDevice": {}, + "countFiles": "{count} files", + "@countFiles": { + "placeholders": { + "count": {} + } + }, + "user": "User", + "@user": {}, + "custom": "Custom", + "@custom": {}, + "foregroundServiceRunning": "This notification appears when the foreground service is running.", + "@foregroundServiceRunning": {}, + "screenSharingTitle": "screen sharing", + "@screenSharingTitle": {}, + "screenSharingDetail": "You are sharing your screen in FuffyChat", + "@screenSharingDetail": {}, + "callingPermissions": "Calling permissions", + "@callingPermissions": {}, + "callingAccount": "Calling account", + "@callingAccount": {}, + "callingAccountDetails": "Allows FluffyChat to use the native android dialer app.", + "@callingAccountDetails": {}, + "appearOnTop": "Appear on top", + "@appearOnTop": {}, + "appearOnTopDetails": "Allows the app to appear on top (not needed if you already have Fluffychat setup as a calling account)", + "@appearOnTopDetails": {}, + "otherCallingPermissions": "Microphone, camera and other FluffyChat permissions", + "@otherCallingPermissions": {}, + "whyIsThisMessageEncrypted": "Why is this message unreadable?", + "@whyIsThisMessageEncrypted": {}, + "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.", + "@noKeyForThisMessage": {}, + "newGroup": "New group", + "@newGroup": {}, + "newSpace": "New space", + "@newSpace": {}, + "enterSpace": "Enter space", + "@enterSpace": {}, + "enterRoom": "Enter room", + "@enterRoom": {}, + "allSpaces": "All spaces", + "@allSpaces": {}, + "numChats": "{number} chats", + "@numChats": { + "type": "text", + "placeholders": { + "number": {} + } + }, + "hideUnimportantStateEvents": "Hide unimportant state events", + "@hideUnimportantStateEvents": {}, + "hidePresences": "Hide Status List?", + "@hidePresences": {}, + "doNotShowAgain": "Do not show again", + "@doNotShowAgain": {}, + "wasDirectChatDisplayName": "Empty chat (was {oldDisplayName})", + "@wasDirectChatDisplayName": { + "type": "text", + "placeholders": { + "oldDisplayName": {} + } + }, + "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities.", + "@newSpaceDescription": {}, + "encryptThisChat": "Encrypt this chat", + "@encryptThisChat": {}, + "disableEncryptionWarning": "For security reasons you can not disable encryption in a chat, where it has been enabled before.", + "@disableEncryptionWarning": {}, + "sorryThatsNotPossible": "Sorry... that is not possible", + "@sorryThatsNotPossible": {}, + "deviceKeys": "Device keys:", + "@deviceKeys": {}, + "reopenChat": "Reopen chat", + "@reopenChat": {}, + "noBackupWarning": "Warning! Without enabling chat backup, you will lose access to your encrypted messages. It is highly recommended to enable the chat backup first before logging out.", + "@noBackupWarning": {}, + "noOtherDevicesFound": "No other devices found", + "@noOtherDevicesFound": {}, + "fileIsTooBigForServer": "Unable to send! The server only supports attachments up to {max}.", + "@fileIsTooBigForServer": { + "type": "text", + "placeholders": { + "max": {} + } + }, + "fileHasBeenSavedAt": "File has been saved at {path}", + "@fileHasBeenSavedAt": { + "type": "text", + "placeholders": { + "path": {} + } + }, + "jumpToLastReadMessage": "Jump to last read message", + "@jumpToLastReadMessage": {}, + "readUpToHere": "Read up to here", + "@readUpToHere": {}, + "jump": "Jump", + "@jump": {}, + "openLinkInBrowser": "Open link in browser", + "@openLinkInBrowser": {}, + "reportErrorDescription": "😭 Oh no. Something went wrong. If you want, you can report this bug to the developers.", + "@reportErrorDescription": {}, + "report": "report", + "@report": {}, + "signInWithPassword": "Sign in with password", + "@signInWithPassword": {}, + "pleaseTryAgainLaterOrChooseDifferentServer": "Please try again later or choose a different server.", + "@pleaseTryAgainLaterOrChooseDifferentServer": {}, + "signInWith": "Sign in with {provider}", + "@signInWith": { + "type": "text", + "placeholders": { + "provider": {} + } + }, + "profileNotFound": "The user could not be found on the server. Maybe there is a connection problem or the user doesn't exist.", + "@profileNotFound": {}, + "setTheme": "Set theme:", + "@setTheme": {}, + "setColorTheme": "Set color theme:", + "@setColorTheme": {}, + "invite": "Invite", + "@invite": {}, + "inviteGroupChat": "📨 Invite group chat", + "@inviteGroupChat": {}, + "invitePrivateChat": "📨 Invite private chat", + "@invitePrivateChat": {}, + "invalidInput": "Invalid input!", + "@invalidInput": {}, + "wrongPinEntered": "Wrong pin entered! Try again in {seconds} seconds...", + "@wrongPinEntered": { + "type": "text", + "placeholders": { + "seconds": {} + } + }, + "pleaseEnterANumber": "Please enter a number greater than 0", + "@pleaseEnterANumber": {}, + "archiveRoomDescription": "The chat will be moved to the archive. Other users will be able to see that you have left the chat.", + "@archiveRoomDescription": {}, + "roomUpgradeDescription": "The chat will then be recreated with the new room version. All participants will be notified that they need to switch to the new chat. You can find out more about room versions at https://spec.matrix.org/latest/rooms/", + "@roomUpgradeDescription": {}, + "removeDevicesDescription": "You will be logged out of this device and will no longer be able to receive messages.", + "@removeDevicesDescription": {}, + "banUserDescription": "The user will be banned from the chat and will not be able to enter the chat again until they are unbanned.", + "@banUserDescription": {}, + "unbanUserDescription": "The user will be able to enter the chat again if they try.", + "@unbanUserDescription": {}, + "kickUserDescription": "The user is kicked out of the chat but not banned. In public chats, the user can rejoin at any time.", + "@kickUserDescription": {}, + "makeAdminDescription": "Once you make this user admin, you may not be able to undo this as they will then have the same permissions as you.", + "@makeAdminDescription": {}, + "pushNotificationsNotAvailable": "Push notifications not available", + "@pushNotificationsNotAvailable": {}, + "learnMore": "Learn more", + "@learnMore": {}, + "yourGlobalUserIdIs": "Your global user-ID is: ", + "@yourGlobalUserIdIs": {}, + "noUsersFoundWithQuery": "Unfortunately no user could be found with \"{query}\". Please check whether you made a typo.", + "@noUsersFoundWithQuery": { + "type": "text", + "placeholders": { + "query": {} + } + }, + "knocking": "Knocking", + "@knocking": {}, + "chatCanBeDiscoveredViaSearchOnServer": "Chat can be discovered via the search on {server}", + "@chatCanBeDiscoveredViaSearchOnServer": { + "type": "text", + "placeholders": { + "server": {} + } + }, + "searchChatsRooms": "Search for #chats, @users...", + "@searchChatsRooms": {}, + "nothingFound": "Nothing found...", + "@nothingFound": {}, + "groupName": "Group name", + "@groupName": {}, + "createGroupAndInviteUsers": "Create a group and invite users", + "@createGroupAndInviteUsers": {}, + "groupCanBeFoundViaSearch": "Group can be found via search", + "@groupCanBeFoundViaSearch": {}, + "wrongRecoveryKey": "Sorry... this does not seem to be the correct recovery key.", + "@wrongRecoveryKey": {}, + "startConversation": "Start conversation", + "@startConversation": {}, + "commandHint_sendraw": "Send raw json", + "@commandHint_sendraw": {}, + "databaseMigrationTitle": "Database is optimized", + "@databaseMigrationTitle": {}, + "databaseMigrationBody": "Please wait. This may take a moment.", + "@databaseMigrationBody": {}, + "leaveEmptyToClearStatus": "Leave empty to clear your status.", + "@leaveEmptyToClearStatus": {}, + "select": "Select", + "@select": {}, + "searchForUsers": "Search for @users...", + "@searchForUsers": {}, + "pleaseEnterYourCurrentPassword": "Please enter your current password", + "@pleaseEnterYourCurrentPassword": {}, + "newPassword": "New password", + "@newPassword": {}, + "pleaseChooseAStrongPassword": "Please choose a strong password", + "@pleaseChooseAStrongPassword": {}, + "passwordsDoNotMatch": "Passwords do not match", + "@passwordsDoNotMatch": {}, + "passwordIsWrong": "Your entered password is wrong", + "@passwordIsWrong": {}, + "publicLink": "Public link", + "@publicLink": {}, + "publicChatAddresses": "Public chat addresses", + "@publicChatAddresses": {}, + "createNewAddress": "Create new address", + "@createNewAddress": {}, + "joinSpace": "Join space", + "@joinSpace": {}, + "publicSpaces": "Public spaces", + "@publicSpaces": {}, + "addChatOrSubSpace": "Add chat or sub space", + "@addChatOrSubSpace": {}, + "subspace": "Subspace", + "@subspace": {}, + "decline": "Decline", + "@decline": {}, + "thisDevice": "This device:", + "@thisDevice": {}, + "initAppError": "An error occured while init the app", + "@initAppError": {}, + "userRole": "User role", + "@userRole": {}, + "minimumPowerLevel": "{level} is the minimum power level.", + "@minimumPowerLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "searchIn": "Search in chat \"{chat}\"...", + "@searchIn": { + "type": "text", + "placeholders": { + "chat": {} + } + }, + "searchMore": "Search more...", + "@searchMore": {}, + "gallery": "Gallery", + "@gallery": {}, + "files": "Files", + "@files": {}, + "databaseBuildErrorBody": "Unable to build the SQlite database. The app tries to use the legacy database for now. Please report this error to the developers at {url}. The error message is: {error}", + "@databaseBuildErrorBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "sessionLostBody": "Your session is lost. Please report this error to the developers at {url}. The error message is: {error}", + "@sessionLostBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "restoreSessionBody": "The app now tries to restore your session from the backup. Please report this error to the developers at {url}. The error message is: {error}", + "@restoreSessionBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "forwardMessageTo": "Forward message to {roomName}?", + "@forwardMessageTo": { + "type": "text", + "placeholders": { + "roomName": {} + } + }, + "sendReadReceipts": "Send read receipts", + "@sendReadReceipts": {}, + "sendTypingNotificationsDescription": "Other participants in a chat can see when you are typing a new message.", + "@sendTypingNotificationsDescription": {}, + "sendReadReceiptsDescription": "Other participants in a chat can see when you have read a message.", + "@sendReadReceiptsDescription": {}, + "formattedMessages": "Formatted messages", + "@formattedMessages": {}, + "formattedMessagesDescription": "Display rich message content like bold text using markdown.", + "@formattedMessagesDescription": {}, + "verifyOtherUser": "🔐 Verify other user", + "@verifyOtherUser": {}, + "verifyOtherUserDescription": "If you verify another user, you can be sure that you know who you are really writing to. 💪\n\nWhen you start a verification, you and the other user will see a popup in the app. There you will then see a series of emojis or numbers that you have to compare with each other.\n\nThe best way to do this is to meet up or start a video call. 👭", + "@verifyOtherUserDescription": {}, + "verifyOtherDevice": "🔐 Verify other device", + "@verifyOtherDevice": {}, + "verifyOtherDeviceDescription": "When you verify another device, those devices can exchange keys, increasing your overall security. 💪 When you start a verification, a popup will appear in the app on both devices. There you will then see a series of emojis or numbers that you have to compare with each other. It's best to have both devices handy before you start the verification. 🤳", + "@verifyOtherDeviceDescription": {}, + "acceptedKeyVerification": "{sender} accepted key verification", + "@acceptedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "canceledKeyVerification": "{sender} canceled key verification", + "@canceledKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "completedKeyVerification": "{sender} completed key verification", + "@completedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "isReadyForKeyVerification": "{sender} is ready for key verification", + "@isReadyForKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "requestedKeyVerification": "{sender} requested key verification", + "@requestedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "startedKeyVerification": "{sender} started key verification", + "@startedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "transparent": "Transparent", + "@transparent": {}, + "incomingMessages": "Incoming messages", + "@incomingMessages": {}, + "stickers": "Stickers", + "@stickers": {}, + "discover": "Discover", + "@discover": {}, + "commandHint_ignore": "Ignore the given matrix ID", + "@commandHint_ignore": {}, + "commandHint_unignore": "Unignore the given matrix ID", + "@commandHint_unignore": {}, + "unreadChatsInApp": "{appname}: {unread} unread chats", + "@unreadChatsInApp": { + "type": "text", + "placeholders": { + "appname": {}, + "unread": {} + } + }, + "noDatabaseEncryption": "Database encryption is not supported on this platform", + "@noDatabaseEncryption": {}, + "thereAreCountUsersBlocked": "Right now there are {count} users blocked.", + "@thereAreCountUsersBlocked": { + "type": "text" + }, + "restricted": "Restricted", + "@restricted": {}, + "knockRestricted": "Knock restricted", + "@knockRestricted": {}, + "goToSpace": "Go to space: {space}", + "@goToSpace": { + "type": "text" + }, + "markAsUnread": "Mark as unread", + "userLevel": "{level} - User", + "@userLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "moderatorLevel": "{level} - Moderator", + "@moderatorLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "adminLevel": "{level} - Admin", + "@adminLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "changeGeneralChatSettings": "Change general chat settings", + "inviteOtherUsers": "Invite other users to this chat", + "changeTheChatPermissions": "Change the chat permissions", + "changeTheVisibilityOfChatHistory": "Change the visibility of the chat history", + "changeTheCanonicalRoomAlias": "Change the main public chat address", + "sendRoomNotifications": "Send a @room notifications", + "changeTheDescriptionOfTheGroup": "Change the description of the chat", + "chatPermissionsDescription": "Define which power level is necessary for certain actions in this chat. The power levels 0, 50 and 100 are usually representing users, moderators and admins, but any gradation is possible.", + "updateInstalled": "🎉 Update {version} installed!", + "@updateInstalled": { + "type": "text", + "placeholders": { + "version": {} + } + }, + "changelog": "Changelog", + "sendCanceled": "Sending canceled", + "loginWithMatrixId": "Login with Matrix-ID", + "discoverHomeservers": "Discover homeservers", + "whatIsAHomeserver": "What is a homeserver?", + "homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.", + "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?", + "calculatingFileSize": "Calculating file size...", + "prepareSendingAttachment": "Prepare sending attachment...", + "sendingAttachment": "Sending attachment...", + "generatingVideoThumbnail": "Generating video thumbnail...", + "compressVideo": "Compressing video...", + "sendingAttachmentCountOfCount": "Sending attachment {index} of {length}...", + "@sendingAttachmentCountOfCount": { + "type": "text", + "placeholders": { + "index": {}, + "length": {} + } + }, + "serverLimitReached": "Server limit reached! Waiting {seconds} seconds...", + "@serverLimitReached": { + "type": "text", + "placeholders": { + "seconds": {} + } + }, + "yesterday": "Gestern" + } + \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..b6ea970 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,2788 @@ +{ + "@@locale": "en", + "alwaysUse24HourFormat": "false", + "@alwaysUse24HourFormat": { + "description": "Set to true to always display time of day in 24 hour format." + }, + "repeatPassword": "Repeat password", + "@repeatPassword": {}, + "notAnImage": "Not an image file.", + "@notAnImage": {}, + "remove": "Remove", + "@remove": { + "type": "text", + "placeholders": {} + }, + "importNow": "Import now", + "@importNow": {}, + "importEmojis": "Import Emojis", + "@importEmojis": {}, + "importFromZipFile": "Import from .zip file", + "@importFromZipFile": {}, + "exportEmotePack": "Export Emote pack as .zip", + "@exportEmotePack": {}, + "replace": "Replace", + "@replace": {}, + "about": "About", + "@about": { + "type": "text", + "placeholders": {} + }, + "accept": "Accept", + "@accept": { + "type": "text", + "placeholders": {} + }, + "acceptedTheInvitation": "👍 {username} accepted the invitation", + "@acceptedTheInvitation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "account": "Account", + "@account": { + "type": "text", + "placeholders": {} + }, + "activatedEndToEndEncryption": "🔐 {username} activated end to end encryption", + "@activatedEndToEndEncryption": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "addEmail": "Add email", + "@addEmail": { + "type": "text", + "placeholders": {} + }, + "confirmMatrixId": "Please confirm your Matrix ID in order to delete your account.", + "@confirmMatrixId": {}, + "supposedMxid": "This should be {mxid}", + "@supposedMxid": { + "type": "text", + "placeholders": { + "mxid": {} + } + }, + "addChatDescription": "Add a chat description...", + "@addChatDescription": {}, + "addToSpace": "Add to space", + "@addToSpace": {}, + "admin": "Admin", + "@admin": { + "type": "text", + "placeholders": {} + }, + "alias": "alias", + "@alias": { + "type": "text", + "placeholders": {} + }, + "all": "All", + "@all": { + "type": "text", + "placeholders": {} + }, + "allChats": "All chats", + "@allChats": { + "type": "text", + "placeholders": {} + }, + "commandHint_googly": "Send some googly eyes", + "@commandHint_googly": {}, + "commandHint_cuddle": "Send a cuddle", + "@commandHint_cuddle": {}, + "commandHint_hug": "Send a hug", + "@commandHint_hug": {}, + "googlyEyesContent": "{senderName} sends you googly eyes", + "@googlyEyesContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "cuddleContent": "{senderName} cuddles you", + "@cuddleContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "hugContent": "{senderName} hugs you", + "@hugContent": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "answeredTheCall": "{senderName} answered the call", + "@answeredTheCall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "anyoneCanJoin": "Anyone can join", + "@anyoneCanJoin": { + "type": "text", + "placeholders": {} + }, + "appLock": "App lock", + "@appLock": { + "type": "text", + "placeholders": {} + }, + "appLockDescription": "Lock the app when not using with a pin code", + "@appLockDescription": {}, + "archive": "Archive", + "@archive": { + "type": "text", + "placeholders": {} + }, + "areGuestsAllowedToJoin": "Are guest users allowed to join", + "@areGuestsAllowedToJoin": { + "type": "text", + "placeholders": {} + }, + "areYouSure": "Are you sure?", + "@areYouSure": { + "type": "text", + "placeholders": {} + }, + "areYouSureYouWantToLogout": "Are you sure you want to log out?", + "@areYouSureYouWantToLogout": { + "type": "text", + "placeholders": {} + }, + "askSSSSSign": "To be able to sign the other person, please enter your secure store passphrase or recovery key.", + "@askSSSSSign": { + "type": "text", + "placeholders": {} + }, + "askVerificationRequest": "Accept this verification request from {username}?", + "@askVerificationRequest": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "autoplayImages": "Automatically play animated stickers and emotes", + "@autoplayImages": { + "type": "text" + }, + "badServerLoginTypesException": "The homeserver supports the login types:\n{serverVersions}\nBut this app supports only:\n{supportedVersions}", + "@badServerLoginTypesException": { + "type": "text", + "placeholders": { + "serverVersions": {}, + "supportedVersions": {} + } + }, + "sendTypingNotifications": "Send typing notifications", + "@sendTypingNotifications": {}, + "swipeRightToLeftToReply": "Swipe right to left to reply", + "@swipeRightToLeftToReply": {}, + "sendOnEnter": "Send on enter", + "@sendOnEnter": {}, + "badServerVersionsException": "The homeserver supports the Spec versions:\n{serverVersions}\nBut this app supports only {supportedVersions}", + "@badServerVersionsException": { + "type": "text", + "placeholders": { + "serverVersions": {}, + "supportedVersions": {} + } + }, + "countChatsAndCountParticipants": "{chats} chats and {participants} participants", + "@countChatsAndCountParticipants": { + "type": "text", + "placeholders": { + "chats": {}, + "participants": {} + } + }, + "noMoreChatsFound": "No more chats found...", + "noChatsFoundHere": "No chats found here yet. Start a new chat with someone by using the button below. ⤵️", + "joinedChats": "Joined chats", + "unread": "Unread", + "space": "Space", + "spaces": "Spaces", + "banFromChat": "Ban from chat", + "@banFromChat": { + "type": "text", + "placeholders": {} + }, + "banned": "Banned", + "@banned": { + "type": "text", + "placeholders": {} + }, + "bannedUser": "{username} banned {targetName}", + "@bannedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "blockDevice": "Block Device", + "@blockDevice": { + "type": "text", + "placeholders": {} + }, + "blocked": "Blocked", + "@blocked": { + "type": "text", + "placeholders": {} + }, + "botMessages": "Bot messages", + "@botMessages": { + "type": "text", + "placeholders": {} + }, + "cancel": "Cancel", + "@cancel": { + "type": "text", + "placeholders": {} + }, + "cantOpenUri": "Can't open the URI {uri}", + "@cantOpenUri": { + "type": "text", + "placeholders": { + "uri": {} + } + }, + "changeDeviceName": "Change device name", + "@changeDeviceName": { + "type": "text", + "placeholders": {} + }, + "changedTheChatAvatar": "{username} changed the chat avatar", + "@changedTheChatAvatar": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheChatDescriptionTo": "{username} changed the chat description to: '{description}'", + "@changedTheChatDescriptionTo": { + "type": "text", + "placeholders": { + "username": {}, + "description": {} + } + }, + "changedTheChatNameTo": "{username} changed the chat name to: '{chatname}'", + "@changedTheChatNameTo": { + "type": "text", + "placeholders": { + "username": {}, + "chatname": {} + } + }, + "changedTheChatPermissions": "{username} changed the chat permissions", + "@changedTheChatPermissions": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheDisplaynameTo": "{username} changed their displayname to: '{displayname}'", + "@changedTheDisplaynameTo": { + "type": "text", + "placeholders": { + "username": {}, + "displayname": {} + } + }, + "changedTheGuestAccessRules": "{username} changed the guest access rules", + "@changedTheGuestAccessRules": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheGuestAccessRulesTo": "{username} changed the guest access rules to: {rules}", + "@changedTheGuestAccessRulesTo": { + "type": "text", + "placeholders": { + "username": {}, + "rules": {} + } + }, + "changedTheHistoryVisibility": "{username} changed the history visibility", + "@changedTheHistoryVisibility": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheHistoryVisibilityTo": "{username} changed the history visibility to: {rules}", + "@changedTheHistoryVisibilityTo": { + "type": "text", + "placeholders": { + "username": {}, + "rules": {} + } + }, + "changedTheJoinRules": "{username} changed the join rules", + "@changedTheJoinRules": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheJoinRulesTo": "{username} changed the join rules to: {joinRules}", + "@changedTheJoinRulesTo": { + "type": "text", + "placeholders": { + "username": {}, + "joinRules": {} + } + }, + "changedTheProfileAvatar": "{username} changed their avatar", + "@changedTheProfileAvatar": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheRoomAliases": "{username} changed the room aliases", + "@changedTheRoomAliases": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changedTheRoomInvitationLink": "{username} changed the invitation link", + "@changedTheRoomInvitationLink": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "changePassword": "Change password", + "@changePassword": { + "type": "text", + "placeholders": {} + }, + "changeTheHomeserver": "Change the homeserver", + "@changeTheHomeserver": { + "type": "text", + "placeholders": {} + }, + "changeTheme": "Change your style", + "@changeTheme": { + "type": "text", + "placeholders": {} + }, + "changeTheNameOfTheGroup": "Change the name of the group", + "@changeTheNameOfTheGroup": { + "type": "text", + "placeholders": {} + }, + "changeYourAvatar": "Change your avatar", + "@changeYourAvatar": { + "type": "text", + "placeholders": {} + }, + "channelCorruptedDecryptError": "The encryption has been corrupted", + "@channelCorruptedDecryptError": { + "type": "text", + "placeholders": {} + }, + "chat": "Chat", + "@chat": { + "type": "text", + "placeholders": {} + }, + "yourChatBackupHasBeenSetUp": "Your chat backup has been set up.", + "@yourChatBackupHasBeenSetUp": {}, + "chatBackup": "Chat backup", + "@chatBackup": { + "type": "text", + "placeholders": {} + }, + "chatBackupDescription": "Your old messages are secured with a recovery key. Please make sure you don't lose it.", + "@chatBackupDescription": { + "type": "text", + "placeholders": {} + }, + "chatDetails": "Chat details", + "@chatDetails": { + "type": "text", + "placeholders": {} + }, + "chatHasBeenAddedToThisSpace": "Chat has been added to this space", + "@chatHasBeenAddedToThisSpace": {}, + "chats": "Chats", + "@chats": { + "type": "text", + "placeholders": {} + }, + "chooseAStrongPassword": "Choose a strong password", + "@chooseAStrongPassword": { + "type": "text", + "placeholders": {} + }, + "clearArchive": "Clear archive", + "@clearArchive": {}, + "close": "Close", + "@close": { + "type": "text", + "placeholders": {} + }, + "commandHint_markasdm": "Mark as direct message room for the giving Matrix ID", + "@commandHint_markasdm": {}, + "commandHint_markasgroup": "Mark as group", + "@commandHint_markasgroup": {}, + "commandHint_ban": "Ban the given user from this room", + "@commandHint_ban": { + "type": "text", + "description": "Usage hint for the command /ban" + }, + "commandHint_clearcache": "Clear cache", + "@commandHint_clearcache": { + "type": "text", + "description": "Usage hint for the command /clearcache" + }, + "commandHint_create": "Create an empty group chat\nUse --no-encryption to disable encryption", + "@commandHint_create": { + "type": "text", + "description": "Usage hint for the command /create" + }, + "commandHint_discardsession": "Discard session", + "@commandHint_discardsession": { + "type": "text", + "description": "Usage hint for the command /discardsession" + }, + "commandHint_dm": "Start a direct chat\nUse --no-encryption to disable encryption", + "@commandHint_dm": { + "type": "text", + "description": "Usage hint for the command /dm" + }, + "commandHint_html": "Send HTML-formatted text", + "@commandHint_html": { + "type": "text", + "description": "Usage hint for the command /html" + }, + "commandHint_invite": "Invite the given user to this room", + "@commandHint_invite": { + "type": "text", + "description": "Usage hint for the command /invite" + }, + "commandHint_join": "Join the given room", + "@commandHint_join": { + "type": "text", + "description": "Usage hint for the command /join" + }, + "commandHint_kick": "Remove the given user from this room", + "@commandHint_kick": { + "type": "text", + "description": "Usage hint for the command /kick" + }, + "commandHint_leave": "Leave this room", + "@commandHint_leave": { + "type": "text", + "description": "Usage hint for the command /leave" + }, + "commandHint_me": "Describe yourself", + "@commandHint_me": { + "type": "text", + "description": "Usage hint for the command /me" + }, + "commandHint_myroomavatar": "Set your picture for this room (by mxc-uri)", + "@commandHint_myroomavatar": { + "type": "text", + "description": "Usage hint for the command /myroomavatar" + }, + "commandHint_myroomnick": "Set your display name for this room", + "@commandHint_myroomnick": { + "type": "text", + "description": "Usage hint for the command /myroomnick" + }, + "commandHint_op": "Set the given user's power level (default: 50)", + "@commandHint_op": { + "type": "text", + "description": "Usage hint for the command /op" + }, + "commandHint_plain": "Send unformatted text", + "@commandHint_plain": { + "type": "text", + "description": "Usage hint for the command /plain" + }, + "commandHint_react": "Send reply as a reaction", + "@commandHint_react": { + "type": "text", + "description": "Usage hint for the command /react" + }, + "commandHint_send": "Send text", + "@commandHint_send": { + "type": "text", + "description": "Usage hint for the command /send" + }, + "commandHint_unban": "Unban the given user from this room", + "@commandHint_unban": { + "type": "text", + "description": "Usage hint for the command /unban" + }, + "commandInvalid": "Command invalid", + "@commandInvalid": { + "type": "text" + }, + "commandMissing": "{command} is not a command.", + "@commandMissing": { + "type": "text", + "placeholders": { + "command": {} + }, + "description": "State that {command} is not a valid /command." + }, + "compareEmojiMatch": "Please compare the emojis", + "@compareEmojiMatch": { + "type": "text", + "placeholders": {} + }, + "compareNumbersMatch": "Please compare the numbers", + "@compareNumbersMatch": { + "type": "text", + "placeholders": {} + }, + "configureChat": "Configure chat", + "@configureChat": { + "type": "text", + "placeholders": {} + }, + "confirm": "Confirm", + "@confirm": { + "type": "text", + "placeholders": {} + }, + "connect": "Connect", + "@connect": { + "type": "text", + "placeholders": {} + }, + "contactHasBeenInvitedToTheGroup": "Contact has been invited to the group", + "@contactHasBeenInvitedToTheGroup": { + "type": "text", + "placeholders": {} + }, + "containsDisplayName": "Contains display name", + "@containsDisplayName": { + "type": "text", + "placeholders": {} + }, + "containsUserName": "Contains username", + "@containsUserName": { + "type": "text", + "placeholders": {} + }, + "contentHasBeenReported": "The content has been reported to the server admins", + "@contentHasBeenReported": { + "type": "text", + "placeholders": {} + }, + "copiedToClipboard": "Copied to clipboard", + "@copiedToClipboard": { + "type": "text", + "placeholders": {} + }, + "copy": "Copy", + "@copy": { + "type": "text", + "placeholders": {} + }, + "copyToClipboard": "Copy to clipboard", + "@copyToClipboard": { + "type": "text", + "placeholders": {} + }, + "couldNotDecryptMessage": "Could not decrypt message: {error}", + "@couldNotDecryptMessage": { + "type": "text", + "placeholders": { + "error": {} + } + }, + "countParticipants": "{count} participants", + "@countParticipants": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "create": "Create", + "@create": { + "type": "text", + "placeholders": {} + }, + "createdTheChat": "💬 {username} created the chat", + "@createdTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "createGroup": "Create group", + "@createGroup": {}, + "createNewSpace": "New space", + "@createNewSpace": { + "type": "text", + "placeholders": {} + }, + "currentlyActive": "Currently active", + "@currentlyActive": { + "type": "text", + "placeholders": {} + }, + "darkTheme": "Dark", + "@darkTheme": { + "type": "text", + "placeholders": {} + }, + "dateAndTimeOfDay": "{date}, {timeOfDay}", + "@dateAndTimeOfDay": { + "type": "text", + "placeholders": { + "date": {}, + "timeOfDay": {} + } + }, + "dateWithoutYear": "{month}-{day}", + "@dateWithoutYear": { + "type": "text", + "placeholders": { + "month": {}, + "day": {} + } + }, + "dateWithYear": "{year}-{month}-{day}", + "@dateWithYear": { + "type": "text", + "placeholders": { + "year": {}, + "month": {}, + "day": {} + } + }, + "deactivateAccountWarning": "This will deactivate your user account. This can not be undone! Are you sure?", + "@deactivateAccountWarning": { + "type": "text", + "placeholders": {} + }, + "defaultPermissionLevel": "Default permission level for new users", + "@defaultPermissionLevel": { + "type": "text", + "placeholders": {} + }, + "delete": "Delete", + "@delete": { + "type": "text", + "placeholders": {} + }, + "deleteAccount": "Delete account", + "@deleteAccount": { + "type": "text", + "placeholders": {} + }, + "deleteMessage": "Delete message", + "@deleteMessage": { + "type": "text", + "placeholders": {} + }, + "device": "Device", + "@device": { + "type": "text", + "placeholders": {} + }, + "deviceId": "Device ID", + "@deviceId": { + "type": "text", + "placeholders": {} + }, + "devices": "Devices", + "@devices": { + "type": "text", + "placeholders": {} + }, + "directChats": "Direct Chats", + "@directChats": { + "type": "text", + "placeholders": {} + }, + "allRooms": "All Group Chats", + "@allRooms": { + "type": "text", + "placeholders": {} + }, + "displaynameHasBeenChanged": "Displayname has been changed", + "@displaynameHasBeenChanged": { + "type": "text", + "placeholders": {} + }, + "downloadFile": "Download file", + "@downloadFile": { + "type": "text", + "placeholders": {} + }, + "edit": "Edit", + "@edit": { + "type": "text", + "placeholders": {} + }, + "editBlockedServers": "Edit blocked servers", + "@editBlockedServers": { + "type": "text", + "placeholders": {} + }, + "chatPermissions": "Chat permissions", + "@chatPermissions": {}, + "editDisplayname": "Edit displayname", + "@editDisplayname": { + "type": "text", + "placeholders": {} + }, + "editRoomAliases": "Edit room aliases", + "@editRoomAliases": { + "type": "text", + "placeholders": {} + }, + "editRoomAvatar": "Edit room avatar", + "@editRoomAvatar": { + "type": "text", + "placeholders": {} + }, + "emoteExists": "Emote already exists!", + "@emoteExists": { + "type": "text", + "placeholders": {} + }, + "emoteInvalid": "Invalid emote shortcode!", + "@emoteInvalid": { + "type": "text", + "placeholders": {} + }, + "emoteKeyboardNoRecents": "Recently-used emotes will appear here...", + "@emoteKeyboardNoRecents": { + "type": "text", + "placeholders": {} + }, + "emotePacks": "Emote packs for room", + "@emotePacks": { + "type": "text", + "placeholders": {} + }, + "emoteSettings": "Emote Settings", + "@emoteSettings": { + "type": "text", + "placeholders": {} + }, + "globalChatId": "Global chat ID", + "@globalChatId": {}, + "accessAndVisibility": "Access and visibility", + "@accessAndVisibility": {}, + "accessAndVisibilityDescription": "Who is allowed to join this chat and how the chat can be discovered.", + "@accessAndVisibilityDescription": {}, + "calls": "Calls", + "@calls": {}, + "customEmojisAndStickers": "Custom emojis and stickers", + "@customEmojisAndStickers": {}, + "customEmojisAndStickersBody": "Add or share custom emojis or stickers which can be used in any chat.", + "@customEmojisAndStickersBody": {}, + "emoteShortcode": "Emote shortcode", + "@emoteShortcode": { + "type": "text", + "placeholders": {} + }, + "emoteWarnNeedToPick": "You need to pick an emote shortcode and an image!", + "@emoteWarnNeedToPick": { + "type": "text", + "placeholders": {} + }, + "emptyChat": "Empty chat", + "@emptyChat": { + "type": "text", + "placeholders": {} + }, + "enableEmotesGlobally": "Enable emote pack globally", + "@enableEmotesGlobally": { + "type": "text", + "placeholders": {} + }, + "enableEncryption": "Enable encryption", + "@enableEncryption": { + "type": "text", + "placeholders": {} + }, + "enableEncryptionWarning": "You won't be able to disable the encryption anymore. Are you sure?", + "@enableEncryptionWarning": { + "type": "text", + "placeholders": {} + }, + "encrypted": "Encrypted", + "@encrypted": { + "type": "text", + "placeholders": {} + }, + "encryption": "Encryption", + "@encryption": { + "type": "text", + "placeholders": {} + }, + "encryptionNotEnabled": "Encryption is not enabled", + "@encryptionNotEnabled": { + "type": "text", + "placeholders": {} + }, + "endedTheCall": "{senderName} ended the call", + "@endedTheCall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "enterAnEmailAddress": "Enter an email address", + "@enterAnEmailAddress": { + "type": "text", + "placeholders": {} + }, + "homeserver": "Homeserver", + "@homeserver": {}, + "enterYourHomeserver": "Enter your homeserver", + "@enterYourHomeserver": { + "type": "text", + "placeholders": {} + }, + "errorObtainingLocation": "Error obtaining location: {error}", + "@errorObtainingLocation": { + "type": "text", + "placeholders": { + "error": {} + } + }, + "everythingReady": "Everything ready!", + "@everythingReady": { + "type": "text", + "placeholders": {} + }, + "extremeOffensive": "Extremely offensive", + "@extremeOffensive": { + "type": "text", + "placeholders": {} + }, + "fileName": "File name", + "@fileName": { + "type": "text", + "placeholders": {} + }, + "fluffychat": "FluffyChat", + "@fluffychat": { + "type": "text", + "placeholders": {} + }, + "fontSize": "Font size", + "@fontSize": { + "type": "text", + "placeholders": {} + }, + "forward": "Forward", + "@forward": { + "type": "text", + "placeholders": {} + }, + "fromJoining": "From joining", + "@fromJoining": { + "type": "text", + "placeholders": {} + }, + "fromTheInvitation": "From the invitation", + "@fromTheInvitation": { + "type": "text", + "placeholders": {} + }, + "goToTheNewRoom": "Go to the new room", + "@goToTheNewRoom": { + "type": "text", + "placeholders": {} + }, + "group": "Group", + "@group": { + "type": "text", + "placeholders": {} + }, + "chatDescription": "Chat description", + "@chatDescription": {}, + "chatDescriptionHasBeenChanged": "Chat description changed", + "@chatDescriptionHasBeenChanged": {}, + "groupIsPublic": "Group is public", + "@groupIsPublic": { + "type": "text", + "placeholders": {} + }, + "groups": "Groups", + "@groups": { + "type": "text", + "placeholders": {} + }, + "groupWith": "Group with {displayname}", + "@groupWith": { + "type": "text", + "placeholders": { + "displayname": {} + } + }, + "guestsAreForbidden": "Guests are forbidden", + "@guestsAreForbidden": { + "type": "text", + "placeholders": {} + }, + "guestsCanJoin": "Guests can join", + "@guestsCanJoin": { + "type": "text", + "placeholders": {} + }, + "hasWithdrawnTheInvitationFor": "{username} has withdrawn the invitation for {targetName}", + "@hasWithdrawnTheInvitationFor": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "help": "Help", + "@help": { + "type": "text", + "placeholders": {} + }, + "hideRedactedEvents": "Hide redacted events", + "@hideRedactedEvents": { + "type": "text", + "placeholders": {} + }, + "hideRedactedMessages": "Hide redacted messages", + "@hideRedactedMessages": {}, + "hideRedactedMessagesBody": "If someone redacts a message, this message won't be visible in the chat anymore.", + "@hideRedactedMessagesBody": {}, + "hideInvalidOrUnknownMessageFormats": "Hide invalid or unknown message formats", + "@hideInvalidOrUnknownMessageFormats": {}, + "howOffensiveIsThisContent": "How offensive is this content?", + "@howOffensiveIsThisContent": { + "type": "text", + "placeholders": {} + }, + "id": "ID", + "@id": { + "type": "text", + "placeholders": {} + }, + "identity": "Identity", + "@identity": { + "type": "text", + "placeholders": {} + }, + "block": "Block", + "@block": {}, + "blockedUsers": "Blocked users", + "@blockedUsers": {}, + "blockListDescription": "You can block users who are disturbing you. You won't be able to receive any messages or room invites from the users on your personal block list.", + "@blockListDescription": {}, + "blockUsername": "Ignore username", + "@blockUsername": {}, + "iHaveClickedOnLink": "I have clicked on the link", + "@iHaveClickedOnLink": { + "type": "text", + "placeholders": {} + }, + "incorrectPassphraseOrKey": "Incorrect passphrase or recovery key", + "@incorrectPassphraseOrKey": { + "type": "text", + "placeholders": {} + }, + "inoffensive": "Inoffensive", + "@inoffensive": { + "type": "text", + "placeholders": {} + }, + "inviteContact": "Invite contact", + "@inviteContact": { + "type": "text", + "placeholders": {} + }, + "inviteContactToGroupQuestion": "Do you want to invite {contact} to the chat \"{groupName}\"?", + "@inviteContactToGroupQuestion": {}, + "inviteContactToGroup": "Invite contact to {groupName}", + "@inviteContactToGroup": { + "type": "text", + "placeholders": { + "groupName": {} + } + }, + "noChatDescriptionYet": "No chat description created yet.", + "@noChatDescriptionYet": {}, + "tryAgain": "Try again", + "@tryAgain": {}, + "invalidServerName": "Invalid server name", + "@invalidServerName": {}, + "invited": "Invited", + "@invited": { + "type": "text", + "placeholders": {} + }, + "redactMessageDescription": "The message will be redacted for all participants in this conversation. This cannot be undone.", + "@redactMessageDescription": {}, + "optionalRedactReason": "(Optional) Reason for redacting this message...", + "@optionalRedactReason": {}, + "invitedUser": "📩 {username} invited {targetName}", + "@invitedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "invitedUsersOnly": "Invited users only", + "@invitedUsersOnly": { + "type": "text", + "placeholders": {} + }, + "inviteForMe": "Invite for me", + "@inviteForMe": { + "type": "text", + "placeholders": {} + }, + "inviteText": "{username} invited you to FluffyChat.\n1. Visit fluffychat.im and install the app \n2. Sign up or sign in \n3. Open the invite link: \n {link}", + "@inviteText": { + "type": "text", + "placeholders": { + "username": {}, + "link": {} + } + }, + "isTyping": "is typing…", + "@isTyping": { + "type": "text", + "placeholders": {} + }, + "joinedTheChat": "👋 {username} joined the chat", + "@joinedTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "joinRoom": "Join room", + "@joinRoom": { + "type": "text", + "placeholders": {} + }, + "kicked": "👞 {username} kicked {targetName}", + "@kicked": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "kickedAndBanned": "🙅 {username} kicked and banned {targetName}", + "@kickedAndBanned": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "kickFromChat": "Kick from chat", + "@kickFromChat": { + "type": "text", + "placeholders": {} + }, + "lastActiveAgo": "Last active: {localizedTimeShort}", + "@lastActiveAgo": { + "type": "text", + "placeholders": { + "localizedTimeShort": {} + } + }, + "leave": "Leave", + "@leave": { + "type": "text", + "placeholders": {} + }, + "leftTheChat": "Left the chat", + "@leftTheChat": { + "type": "text", + "placeholders": {} + }, + "license": "License", + "@license": { + "type": "text", + "placeholders": {} + }, + "lightTheme": "Light", + "@lightTheme": { + "type": "text", + "placeholders": {} + }, + "loadCountMoreParticipants": "Load {count} more participants", + "@loadCountMoreParticipants": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "dehydrate": "Export session and wipe device", + "@dehydrate": {}, + "dehydrateWarning": "This action cannot be undone. Ensure you safely store the backup file.", + "@dehydrateWarning": {}, + "dehydrateTor": "TOR Users: Export session", + "@dehydrateTor": {}, + "dehydrateTorLong": "For TOR users, it is recommended to export the session before closing the window.", + "@dehydrateTorLong": {}, + "hydrateTor": "TOR Users: Import session export", + "@hydrateTor": {}, + "hydrateTorLong": "Did you export your session last time on TOR? Quickly import it and continue chatting.", + "@hydrateTorLong": {}, + "hydrate": "Restore from backup file", + "@hydrate": {}, + "loadingPleaseWait": "Loading… Please wait.", + "@loadingPleaseWait": { + "type": "text", + "placeholders": {} + }, + "loadMore": "Load more…", + "@loadMore": { + "type": "text", + "placeholders": {} + }, + "locationDisabledNotice": "Location services are disabled. Please enable them to be able to share your location.", + "@locationDisabledNotice": { + "type": "text", + "placeholders": {} + }, + "locationPermissionDeniedNotice": "Location permission denied. Please grant them to be able to share your location.", + "@locationPermissionDeniedNotice": { + "type": "text", + "placeholders": {} + }, + "login": "Login", + "@login": { + "type": "text", + "placeholders": {} + }, + "logInTo": "Log in to {homeserver}", + "@logInTo": { + "type": "text", + "placeholders": { + "homeserver": {} + } + }, + "logout": "Logout", + "@logout": { + "type": "text", + "placeholders": {} + }, + "memberChanges": "Member changes", + "@memberChanges": { + "type": "text", + "placeholders": {} + }, + "mention": "Mention", + "@mention": { + "type": "text", + "placeholders": {} + }, + "messages": "Messages", + "@messages": { + "type": "text", + "placeholders": {} + }, + "messagesStyle": "Messages:", + "@messagesStyle": {}, + "moderator": "Moderator", + "@moderator": { + "type": "text", + "placeholders": {} + }, + "muteChat": "Mute chat", + "@muteChat": { + "type": "text", + "placeholders": {} + }, + "needPantalaimonWarning": "Please be aware that you need Pantalaimon to use end-to-end encryption for now.", + "@needPantalaimonWarning": { + "type": "text", + "placeholders": {} + }, + "newChat": "New chat", + "@newChat": { + "type": "text", + "placeholders": {} + }, + "newMessageInFluffyChat": "💬 New message in FluffyChat", + "@newMessageInFluffyChat": { + "type": "text", + "placeholders": {} + }, + "newVerificationRequest": "New verification request!", + "@newVerificationRequest": { + "type": "text", + "placeholders": {} + }, + "next": "Next", + "@next": { + "type": "text", + "placeholders": {} + }, + "no": "No", + "@no": { + "type": "text", + "placeholders": {} + }, + "noConnectionToTheServer": "No connection to the server", + "@noConnectionToTheServer": { + "type": "text", + "placeholders": {} + }, + "noEmotesFound": "No emotes found. 😕", + "@noEmotesFound": { + "type": "text", + "placeholders": {} + }, + "noEncryptionForPublicRooms": "You can only activate encryption as soon as the room is no longer publicly accessible.", + "@noEncryptionForPublicRooms": { + "type": "text", + "placeholders": {} + }, + "noGoogleServicesWarning": "Firebase Cloud Messaging doesn't appear to be available on your device. To still receive push notifications, we recommend installing ntfy. With ntfy or another Unified Push provider you can receive push notifications in a data secure way. You can download ntfy from the PlayStore or from F-Droid.", + "@noGoogleServicesWarning": { + "type": "text", + "placeholders": {} + }, + "noMatrixServer": "{server1} is no matrix server, use {server2} instead?", + "@noMatrixServer": { + "type": "text", + "placeholders": { + "server1": {}, + "server2": {} + } + }, + "shareInviteLink": "Share invite link", + "@shareInviteLink": {}, + "scanQrCode": "Scan QR code", + "@scanQrCode": {}, + "none": "None", + "@none": { + "type": "text", + "placeholders": {} + }, + "noPasswordRecoveryDescription": "You have not added a way to recover your password yet.", + "@noPasswordRecoveryDescription": { + "type": "text", + "placeholders": {} + }, + "noPermission": "No permission", + "@noPermission": { + "type": "text", + "placeholders": {} + }, + "noRoomsFound": "No rooms found…", + "@noRoomsFound": { + "type": "text", + "placeholders": {} + }, + "notifications": "Notifications", + "@notifications": { + "type": "text", + "placeholders": {} + }, + "notificationsEnabledForThisAccount": "Notifications enabled for this account", + "@notificationsEnabledForThisAccount": { + "type": "text", + "placeholders": {} + }, + "numUsersTyping": "{count} users are typing…", + "@numUsersTyping": { + "type": "text", + "placeholders": { + "count": {} + } + }, + "obtainingLocation": "Obtaining location…", + "@obtainingLocation": { + "type": "text", + "placeholders": {} + }, + "offensive": "Offensive", + "@offensive": { + "type": "text", + "placeholders": {} + }, + "offline": "Offline", + "@offline": { + "type": "text", + "placeholders": {} + }, + "ok": "Ok", + "@ok": { + "type": "text", + "placeholders": {} + }, + "online": "Online", + "@online": { + "type": "text", + "placeholders": {} + }, + "onlineKeyBackupEnabled": "Online Key Backup is enabled", + "@onlineKeyBackupEnabled": { + "type": "text", + "placeholders": {} + }, + "oopsPushError": "Oops! Unfortunately, an error occurred when setting up the push notifications.", + "@oopsPushError": { + "type": "text", + "placeholders": {} + }, + "oopsSomethingWentWrong": "Oops, something went wrong…", + "@oopsSomethingWentWrong": { + "type": "text", + "placeholders": {} + }, + "openAppToReadMessages": "Open app to read messages", + "@openAppToReadMessages": { + "type": "text", + "placeholders": {} + }, + "openCamera": "Open camera", + "@openCamera": { + "type": "text", + "placeholders": {} + }, + "openVideoCamera": "Open camera for a video", + "@openVideoCamera": { + "type": "text", + "placeholders": {} + }, + "oneClientLoggedOut": "One of your clients has been logged out", + "@oneClientLoggedOut": {}, + "addAccount": "Add account", + "@addAccount": {}, + "editBundlesForAccount": "Edit bundles for this account", + "@editBundlesForAccount": {}, + "addToBundle": "Add to bundle", + "@addToBundle": {}, + "removeFromBundle": "Remove from this bundle", + "@removeFromBundle": {}, + "bundleName": "Bundle name", + "@bundleName": {}, + "enableMultiAccounts": "(BETA) Enable multi accounts on this device", + "@enableMultiAccounts": {}, + "openInMaps": "Open in maps", + "@openInMaps": { + "type": "text", + "placeholders": {} + }, + "link": "Link", + "@link": {}, + "serverRequiresEmail": "This server needs to validate your email address for registration.", + "@serverRequiresEmail": {}, + "or": "Or", + "@or": { + "type": "text", + "placeholders": {} + }, + "participant": "Participant", + "@participant": { + "type": "text", + "placeholders": {} + }, + "passphraseOrKey": "passphrase or recovery key", + "@passphraseOrKey": { + "type": "text", + "placeholders": {} + }, + "password": "Password", + "@password": { + "type": "text", + "placeholders": {} + }, + "passwordForgotten": "Password forgotten", + "@passwordForgotten": { + "type": "text", + "placeholders": {} + }, + "passwordHasBeenChanged": "Password has been changed", + "@passwordHasBeenChanged": { + "type": "text", + "placeholders": {} + }, + "hideMemberChangesInPublicChats": "Hide member changes in public chats", + "@hideMemberChangesInPublicChats": {}, + "hideMemberChangesInPublicChatsBody": "Do not show in the chat timeline if someone joins or leaves a public chat to improve readability.", + "@hideMemberChangesInPublicChatsBody": {}, + "overview": "Overview", + "@overview": {}, + "notifyMeFor": "Notify me for", + "@notifyMeFor": {}, + "passwordRecoverySettings": "Password recovery settings", + "@passwordRecoverySettings": {}, + "passwordRecovery": "Password recovery", + "@passwordRecovery": { + "type": "text", + "placeholders": {} + }, + "people": "People", + "@people": { + "type": "text", + "placeholders": {} + }, + "pickImage": "Pick an image", + "@pickImage": { + "type": "text", + "placeholders": {} + }, + "pin": "Pin", + "@pin": { + "type": "text", + "placeholders": {} + }, + "play": "Play {fileName}", + "@play": { + "type": "text", + "placeholders": { + "fileName": {} + } + }, + "pleaseChoose": "Please choose", + "@pleaseChoose": { + "type": "text", + "placeholders": {} + }, + "pleaseChooseAPasscode": "Please choose a pass code", + "@pleaseChooseAPasscode": { + "type": "text", + "placeholders": {} + }, + "pleaseClickOnLink": "Please click on the link in the email and then proceed.", + "@pleaseClickOnLink": { + "type": "text", + "placeholders": {} + }, + "pleaseEnter4Digits": "Please enter 4 digits or leave empty to disable app lock.", + "@pleaseEnter4Digits": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterRecoveryKey": "Please enter your recovery key:", + "@pleaseEnterRecoveryKey": {}, + "pleaseEnterYourPassword": "Please enter your password", + "@pleaseEnterYourPassword": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterYourPin": "Please enter your pin", + "@pleaseEnterYourPin": { + "type": "text", + "placeholders": {} + }, + "pleaseEnterYourUsername": "Please enter your username", + "@pleaseEnterYourUsername": { + "type": "text", + "placeholders": {} + }, + "pleaseFollowInstructionsOnWeb": "Please follow the instructions on the website and tap on next.", + "@pleaseFollowInstructionsOnWeb": { + "type": "text", + "placeholders": {} + }, + "privacy": "Privacy", + "@privacy": { + "type": "text", + "placeholders": {} + }, + "publicRooms": "Public Rooms", + "@publicRooms": { + "type": "text", + "placeholders": {} + }, + "pushRules": "Push rules", + "@pushRules": { + "type": "text", + "placeholders": {} + }, + "reason": "Reason", + "@reason": { + "type": "text", + "placeholders": {} + }, + "recording": "Recording", + "@recording": { + "type": "text", + "placeholders": {} + }, + "redactedBy": "Redacted by {username}", + "@redactedBy": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "directChat": "Direct chat", + "@directChat": {}, + "redactedByBecause": "Redacted by {username} because: \"{reason}\"", + "@redactedByBecause": { + "type": "text", + "placeholders": { + "username": {}, + "reason": {} + } + }, + "redactedAnEvent": "{username} redacted an event", + "@redactedAnEvent": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "redactMessage": "Redact message", + "@redactMessage": { + "type": "text", + "placeholders": {} + }, + "register": "Register", + "@register": { + "type": "text", + "placeholders": {} + }, + "reject": "Reject", + "@reject": { + "type": "text", + "placeholders": {} + }, + "rejectedTheInvitation": "{username} rejected the invitation", + "@rejectedTheInvitation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "rejoin": "Rejoin", + "@rejoin": { + "type": "text", + "placeholders": {} + }, + "removeAllOtherDevices": "Remove all other devices", + "@removeAllOtherDevices": { + "type": "text", + "placeholders": {} + }, + "removedBy": "Removed by {username}", + "@removedBy": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "removeDevice": "Remove device", + "@removeDevice": { + "type": "text", + "placeholders": {} + }, + "unbanFromChat": "Unban from chat", + "@unbanFromChat": { + "type": "text", + "placeholders": {} + }, + "removeYourAvatar": "Remove your avatar", + "@removeYourAvatar": { + "type": "text", + "placeholders": {} + }, + "replaceRoomWithNewerVersion": "Replace room with newer version", + "@replaceRoomWithNewerVersion": { + "type": "text", + "placeholders": {} + }, + "reply": "Reply", + "@reply": { + "type": "text", + "placeholders": {} + }, + "reportMessage": "Report message", + "@reportMessage": { + "type": "text", + "placeholders": {} + }, + "requestPermission": "Request permission", + "@requestPermission": { + "type": "text", + "placeholders": {} + }, + "roomHasBeenUpgraded": "Room has been upgraded", + "@roomHasBeenUpgraded": { + "type": "text", + "placeholders": {} + }, + "roomVersion": "Room version", + "@roomVersion": { + "type": "text", + "placeholders": {} + }, + "saveFile": "Save file", + "@saveFile": { + "type": "text", + "placeholders": {} + }, + "search": "Search", + "@search": { + "type": "text", + "placeholders": {} + }, + "security": "Security", + "@security": { + "type": "text", + "placeholders": {} + }, + "recoveryKey": "Recovery key", + "@recoveryKey": {}, + "recoveryKeyLost": "Recovery key lost?", + "@recoveryKeyLost": {}, + "seenByUser": "Seen by {username}", + "@seenByUser": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "send": "Send", + "@send": { + "type": "text", + "placeholders": {} + }, + "sendAMessage": "Send a message", + "@sendAMessage": { + "type": "text", + "placeholders": {} + }, + "sendAsText": "Send as text", + "@sendAsText": { + "type": "text" + }, + "sendAudio": "Send audio", + "@sendAudio": { + "type": "text", + "placeholders": {} + }, + "sendFile": "Send file", + "@sendFile": { + "type": "text", + "placeholders": {} + }, + "sendImage": "Send image", + "@sendImage": { + "type": "text", + "placeholders": {} + }, + "sendMessages": "Send messages", + "@sendMessages": { + "type": "text", + "placeholders": {} + }, + "sendOriginal": "Send original", + "@sendOriginal": { + "type": "text", + "placeholders": {} + }, + "sendSticker": "Send sticker", + "@sendSticker": { + "type": "text", + "placeholders": {} + }, + "sendVideo": "Send video", + "@sendVideo": { + "type": "text", + "placeholders": {} + }, + "sentAFile": "📁 {username} sent a file", + "@sentAFile": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAnAudio": "🎤 {username} sent an audio", + "@sentAnAudio": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAPicture": "🖼️ {username} sent a picture", + "@sentAPicture": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentASticker": "😊 {username} sent a sticker", + "@sentASticker": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentAVideo": "🎥 {username} sent a video", + "@sentAVideo": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "sentCallInformations": "{senderName} sent call information", + "@sentCallInformations": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "separateChatTypes": "Separate Direct Chats and Groups", + "@separateChatTypes": { + "type": "text", + "placeholders": {} + }, + "setAsCanonicalAlias": "Set as main alias", + "@setAsCanonicalAlias": { + "type": "text", + "placeholders": {} + }, + "setCustomEmotes": "Set custom emotes", + "@setCustomEmotes": { + "type": "text", + "placeholders": {} + }, + "setChatDescription": "Set chat description", + "@setChatDescription": {}, + "setInvitationLink": "Set invitation link", + "@setInvitationLink": { + "type": "text", + "placeholders": {} + }, + "setPermissionsLevel": "Set permissions level", + "@setPermissionsLevel": { + "type": "text", + "placeholders": {} + }, + "setStatus": "Set status", + "@setStatus": { + "type": "text", + "placeholders": {} + }, + "settings": "Settings", + "@settings": { + "type": "text", + "placeholders": {} + }, + "share": "Share", + "@share": { + "type": "text", + "placeholders": {} + }, + "sharedTheLocation": "{username} shared their location", + "@sharedTheLocation": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "shareLocation": "Share location", + "@shareLocation": { + "type": "text", + "placeholders": {} + }, + "showPassword": "Show password", + "@showPassword": { + "type": "text", + "placeholders": {} + }, + "presenceStyle": "Presence:", + "@presenceStyle": { + "type": "text", + "placeholders": {} + }, + "presencesToggle": "Show status messages from other users", + "@presencesToggle": { + "type": "text", + "placeholders": {} + }, + "singlesignon": "Single Sign on", + "@singlesignon": { + "type": "text", + "placeholders": {} + }, + "skip": "Skip", + "@skip": { + "type": "text", + "placeholders": {} + }, + "sourceCode": "Source code", + "@sourceCode": { + "type": "text", + "placeholders": {} + }, + "spaceIsPublic": "Space is public", + "@spaceIsPublic": { + "type": "text", + "placeholders": {} + }, + "spaceName": "Space name", + "@spaceName": { + "type": "text", + "placeholders": {} + }, + "startedACall": "{senderName} started a call", + "@startedACall": { + "type": "text", + "placeholders": { + "senderName": {} + } + }, + "startFirstChat": "Start your first chat", + "@startFirstChat": {}, + "status": "Status", + "@status": { + "type": "text", + "placeholders": {} + }, + "statusExampleMessage": "How are you today?", + "@statusExampleMessage": { + "type": "text", + "placeholders": {} + }, + "submit": "Submit", + "@submit": { + "type": "text", + "placeholders": {} + }, + "synchronizingPleaseWait": "Synchronizing… Please wait.", + "@synchronizingPleaseWait": { + "type": "text", + "placeholders": {} + }, + "systemTheme": "System", + "@systemTheme": { + "type": "text", + "placeholders": {} + }, + "theyDontMatch": "They Don't Match", + "@theyDontMatch": { + "type": "text", + "placeholders": {} + }, + "theyMatch": "They Match", + "@theyMatch": { + "type": "text", + "placeholders": {} + }, + "title": "FluffyChat", + "@title": { + "description": "Title for the application", + "type": "text", + "placeholders": {} + }, + "toggleFavorite": "Toggle Favorite", + "@toggleFavorite": { + "type": "text", + "placeholders": {} + }, + "toggleMuted": "Toggle Muted", + "@toggleMuted": { + "type": "text", + "placeholders": {} + }, + "toggleUnread": "Mark Read/Unread", + "@toggleUnread": { + "type": "text", + "placeholders": {} + }, + "tooManyRequestsWarning": "Too many requests. Please try again later!", + "@tooManyRequestsWarning": { + "type": "text", + "placeholders": {} + }, + "transferFromAnotherDevice": "Transfer from another device", + "@transferFromAnotherDevice": { + "type": "text", + "placeholders": {} + }, + "tryToSendAgain": "Try to send again", + "@tryToSendAgain": { + "type": "text", + "placeholders": {} + }, + "unavailable": "Unavailable", + "@unavailable": { + "type": "text", + "placeholders": {} + }, + "unbannedUser": "{username} unbanned {targetName}", + "@unbannedUser": { + "type": "text", + "placeholders": { + "username": {}, + "targetName": {} + } + }, + "unblockDevice": "Unblock Device", + "@unblockDevice": { + "type": "text", + "placeholders": {} + }, + "unknownDevice": "Unknown device", + "@unknownDevice": { + "type": "text", + "placeholders": {} + }, + "unknownEncryptionAlgorithm": "Unknown encryption algorithm", + "@unknownEncryptionAlgorithm": { + "type": "text", + "placeholders": {} + }, + "unknownEvent": "Unknown event '{type}'", + "@unknownEvent": { + "type": "text", + "placeholders": { + "type": {} + } + }, + "unmuteChat": "Unmute chat", + "@unmuteChat": { + "type": "text", + "placeholders": {} + }, + "unpin": "Unpin", + "@unpin": { + "type": "text", + "placeholders": {} + }, + "unreadChats": "{unreadCount, plural, =1{1 unread chat} other{{unreadCount} unread chats}}", + "@unreadChats": { + "type": "text", + "placeholders": { + "unreadCount": {} + } + }, + "userAndOthersAreTyping": "{username} and {count} others are typing…", + "@userAndOthersAreTyping": { + "type": "text", + "placeholders": { + "username": {}, + "count": {} + } + }, + "userAndUserAreTyping": "{username} and {username2} are typing…", + "@userAndUserAreTyping": { + "type": "text", + "placeholders": { + "username": {}, + "username2": {} + } + }, + "userIsTyping": "{username} is typing…", + "@userIsTyping": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "userLeftTheChat": "🚪 {username} left the chat", + "@userLeftTheChat": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "username": "Username", + "@username": { + "type": "text", + "placeholders": {} + }, + "userSentUnknownEvent": "{username} sent a {type} event", + "@userSentUnknownEvent": { + "type": "text", + "placeholders": { + "username": {}, + "type": {} + } + }, + "unverified": "Unverified", + "@unverified": {}, + "verified": "Verified", + "@verified": { + "type": "text", + "placeholders": {} + }, + "verify": "Verify", + "@verify": { + "type": "text", + "placeholders": {} + }, + "verifyStart": "Start Verification", + "@verifyStart": { + "type": "text", + "placeholders": {} + }, + "verifySuccess": "You successfully verified!", + "@verifySuccess": { + "type": "text", + "placeholders": {} + }, + "verifyTitle": "Verifying other account", + "@verifyTitle": { + "type": "text", + "placeholders": {} + }, + "videoCall": "Video call", + "@videoCall": { + "type": "text", + "placeholders": {} + }, + "visibilityOfTheChatHistory": "Visibility of the chat history", + "@visibilityOfTheChatHistory": { + "type": "text", + "placeholders": {} + }, + "visibleForAllParticipants": "Visible for all participants", + "@visibleForAllParticipants": { + "type": "text", + "placeholders": {} + }, + "visibleForEveryone": "Visible for everyone", + "@visibleForEveryone": { + "type": "text", + "placeholders": {} + }, + "voiceMessage": "Voice message", + "@voiceMessage": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerAcceptRequest": "Waiting for partner to accept the request…", + "@waitingPartnerAcceptRequest": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerEmoji": "Waiting for partner to accept the emoji…", + "@waitingPartnerEmoji": { + "type": "text", + "placeholders": {} + }, + "waitingPartnerNumbers": "Waiting for partner to accept the numbers…", + "@waitingPartnerNumbers": { + "type": "text", + "placeholders": {} + }, + "wallpaper": "Wallpaper:", + "@wallpaper": { + "type": "text", + "placeholders": {} + }, + "warning": "Warning!", + "@warning": { + "type": "text", + "placeholders": {} + }, + "weSentYouAnEmail": "We sent you an email", + "@weSentYouAnEmail": { + "type": "text", + "placeholders": {} + }, + "whoCanPerformWhichAction": "Who can perform which action", + "@whoCanPerformWhichAction": { + "type": "text", + "placeholders": {} + }, + "whoIsAllowedToJoinThisGroup": "Who is allowed to join this group", + "@whoIsAllowedToJoinThisGroup": { + "type": "text", + "placeholders": {} + }, + "whyDoYouWantToReportThis": "Why do you want to report this?", + "@whyDoYouWantToReportThis": { + "type": "text", + "placeholders": {} + }, + "wipeChatBackup": "Wipe your chat backup to create a new recovery key?", + "@wipeChatBackup": { + "type": "text", + "placeholders": {} + }, + "withTheseAddressesRecoveryDescription": "With these addresses you can recover your password.", + "@withTheseAddressesRecoveryDescription": { + "type": "text", + "placeholders": {} + }, + "writeAMessage": "Write a message…", + "@writeAMessage": { + "type": "text", + "placeholders": {} + }, + "yes": "Yes", + "@yes": { + "type": "text", + "placeholders": {} + }, + "you": "You", + "@you": { + "type": "text", + "placeholders": {} + }, + "youAreNoLongerParticipatingInThisChat": "You are no longer participating in this chat", + "@youAreNoLongerParticipatingInThisChat": { + "type": "text", + "placeholders": {} + }, + "youHaveBeenBannedFromThisChat": "You have been banned from this chat", + "@youHaveBeenBannedFromThisChat": { + "type": "text", + "placeholders": {} + }, + "yourPublicKey": "Your public key", + "@yourPublicKey": { + "type": "text", + "placeholders": {} + }, + "messageInfo": "Message info", + "@messageInfo": {}, + "time": "Time", + "@time": {}, + "messageType": "Message Type", + "@messageType": {}, + "sender": "Sender", + "@sender": {}, + "openGallery": "Open gallery", + "@openGallery": {}, + "removeFromSpace": "Remove from space", + "@removeFromSpace": {}, + "addToSpaceDescription": "Select a space to add this chat to it.", + "@addToSpaceDescription": {}, + "start": "Start", + "@start": {}, + "pleaseEnterRecoveryKeyDescription": "To unlock your old messages, please enter your recovery key that has been generated in a previous session. Your recovery key is NOT your password.", + "@pleaseEnterRecoveryKeyDescription": {}, + "publish": "Publish", + "@publish": {}, + "videoWithSize": "Video ({size})", + "@videoWithSize": { + "type": "text", + "placeholders": { + "size": {} + } + }, + "openChat": "Open Chat", + "@openChat": {}, + "markAsRead": "Mark as read", + "@markAsRead": {}, + "reportUser": "Report user", + "@reportUser": {}, + "dismiss": "Dismiss", + "@dismiss": {}, + "reactedWith": "{sender} reacted with {reaction}", + "@reactedWith": { + "type": "text", + "placeholders": { + "sender": {}, + "reaction": {} + } + }, + "pinMessage": "Pin to room", + "@pinMessage": {}, + "confirmEventUnpin": "Are you sure to permanently unpin the event?", + "@confirmEventUnpin": {}, + "emojis": "Emojis", + "@emojis": {}, + "placeCall": "Place call", + "@placeCall": {}, + "voiceCall": "Voice call", + "@voiceCall": {}, + "unsupportedAndroidVersion": "Unsupported Android version", + "@unsupportedAndroidVersion": {}, + "unsupportedAndroidVersionLong": "This feature requires a newer Android version. Please check for updates or Lineage OS support.", + "@unsupportedAndroidVersionLong": {}, + "videoCallsBetaWarning": "Please note that video calls are currently in beta. They might not work as expected or work at all on all platforms.", + "@videoCallsBetaWarning": {}, + "experimentalVideoCalls": "Experimental video calls", + "@experimentalVideoCalls": {}, + "emailOrUsername": "Email or username", + "@emailOrUsername": {}, + "indexedDbErrorTitle": "Private mode issues", + "@indexedDbErrorTitle": {}, + "indexedDbErrorLong": "The message storage is unfortunately not enabled in private mode by default.\nPlease visit\n - about:config\n - set dom.indexedDB.privateBrowsing.enabled to true\nOtherwise, it is not possible to run FluffyChat.", + "@indexedDbErrorLong": {}, + "switchToAccount": "Switch to account {number}", + "@switchToAccount": { + "type": "text", + "placeholders": { + "number": {} + } + }, + "nextAccount": "Next account", + "@nextAccount": {}, + "previousAccount": "Previous account", + "@previousAccount": {}, + "addWidget": "Add widget", + "@addWidget": {}, + "widgetVideo": "Video", + "@widgetVideo": {}, + "widgetEtherpad": "Text note", + "@widgetEtherpad": {}, + "widgetJitsi": "Jitsi Meet", + "@widgetJitsi": {}, + "widgetCustom": "Custom", + "@widgetCustom": {}, + "widgetName": "Name", + "@widgetName": {}, + "widgetUrlError": "This is not a valid URL.", + "@widgetUrlError": {}, + "widgetNameError": "Please provide a display name.", + "@widgetNameError": {}, + "errorAddingWidget": "Error adding the widget.", + "@errorAddingWidget": {}, + "youRejectedTheInvitation": "You rejected the invitation", + "@youRejectedTheInvitation": {}, + "youJoinedTheChat": "You joined the chat", + "@youJoinedTheChat": {}, + "youAcceptedTheInvitation": "👍 You accepted the invitation", + "@youAcceptedTheInvitation": {}, + "youBannedUser": "You banned {user}", + "@youBannedUser": { + "placeholders": { + "user": {} + } + }, + "youHaveWithdrawnTheInvitationFor": "You have withdrawn the invitation for {user}", + "@youHaveWithdrawnTheInvitationFor": { + "placeholders": { + "user": {} + } + }, + "youInvitedToBy": "📩 You have been invited via link to:\n{alias}", + "@youInvitedToBy": { + "placeholders": { + "alias": {} + } + }, + "youInvitedBy": "📩 You have been invited by {user}", + "@youInvitedBy": { + "placeholders": { + "user": {} + } + }, + "invitedBy": "📩 Invited by {user}", + "@invitedBy": { + "placeholders": { + "user": {} + } + }, + "youInvitedUser": "📩 You invited {user}", + "@youInvitedUser": { + "placeholders": { + "user": {} + } + }, + "youKicked": "👞 You kicked {user}", + "@youKicked": { + "placeholders": { + "user": {} + } + }, + "youKickedAndBanned": "🙅 You kicked and banned {user}", + "@youKickedAndBanned": { + "placeholders": { + "user": {} + } + }, + "youUnbannedUser": "You unbanned {user}", + "@youUnbannedUser": { + "placeholders": { + "user": {} + } + }, + "hasKnocked": "🚪 {user} has knocked", + "@hasKnocked": { + "placeholders": { + "user": {} + } + }, + "usersMustKnock": "Users must knock", + "@usersMustKnock": {}, + "noOneCanJoin": "No one can join", + "@noOneCanJoin": {}, + "userWouldLikeToChangeTheChat": "{user} would like to join the chat.", + "@userWouldLikeToChangeTheChat": { + "placeholders": { + "user": {} + } + }, + "noPublicLinkHasBeenCreatedYet": "No public link has been created yet", + "@noPublicLinkHasBeenCreatedYet": {}, + "knock": "Knock", + "@knock": {}, + "users": "Users", + "@users": {}, + "unlockOldMessages": "Unlock old messages", + "@unlockOldMessages": {}, + "storeInSecureStorageDescription": "Store the recovery key in the secure storage of this device.", + "@storeInSecureStorageDescription": {}, + "saveKeyManuallyDescription": "Save this key manually by triggering the system share dialog or clipboard.", + "@saveKeyManuallyDescription": {}, + "storeInAndroidKeystore": "Store in Android KeyStore", + "@storeInAndroidKeystore": {}, + "storeInAppleKeyChain": "Store in Apple KeyChain", + "@storeInAppleKeyChain": {}, + "storeSecurlyOnThisDevice": "Store securely on this device", + "@storeSecurlyOnThisDevice": {}, + "countFiles": "{count} files", + "@countFiles": { + "placeholders": { + "count": {} + } + }, + "user": "User", + "@user": {}, + "custom": "Custom", + "@custom": {}, + "foregroundServiceRunning": "This notification appears when the foreground service is running.", + "@foregroundServiceRunning": {}, + "screenSharingTitle": "screen sharing", + "@screenSharingTitle": {}, + "screenSharingDetail": "You are sharing your screen in FuffyChat", + "@screenSharingDetail": {}, + "callingPermissions": "Calling permissions", + "@callingPermissions": {}, + "callingAccount": "Calling account", + "@callingAccount": {}, + "callingAccountDetails": "Allows FluffyChat to use the native android dialer app.", + "@callingAccountDetails": {}, + "appearOnTop": "Appear on top", + "@appearOnTop": {}, + "appearOnTopDetails": "Allows the app to appear on top (not needed if you already have Fluffychat setup as a calling account)", + "@appearOnTopDetails": {}, + "otherCallingPermissions": "Microphone, camera and other FluffyChat permissions", + "@otherCallingPermissions": {}, + "whyIsThisMessageEncrypted": "Why is this message unreadable?", + "@whyIsThisMessageEncrypted": {}, + "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.", + "@noKeyForThisMessage": {}, + "newGroup": "New group", + "@newGroup": {}, + "newSpace": "New space", + "@newSpace": {}, + "enterSpace": "Enter space", + "@enterSpace": {}, + "enterRoom": "Enter room", + "@enterRoom": {}, + "allSpaces": "All spaces", + "@allSpaces": {}, + "numChats": "{number} chats", + "@numChats": { + "type": "text", + "placeholders": { + "number": {} + } + }, + "hideUnimportantStateEvents": "Hide unimportant state events", + "@hideUnimportantStateEvents": {}, + "hidePresences": "Hide Status List?", + "@hidePresences": {}, + "doNotShowAgain": "Do not show again", + "@doNotShowAgain": {}, + "wasDirectChatDisplayName": "Empty chat (was {oldDisplayName})", + "@wasDirectChatDisplayName": { + "type": "text", + "placeholders": { + "oldDisplayName": {} + } + }, + "newSpaceDescription": "Spaces allows you to consolidate your chats and build private or public communities.", + "@newSpaceDescription": {}, + "encryptThisChat": "Encrypt this chat", + "@encryptThisChat": {}, + "disableEncryptionWarning": "For security reasons you can not disable encryption in a chat, where it has been enabled before.", + "@disableEncryptionWarning": {}, + "sorryThatsNotPossible": "Sorry... that is not possible", + "@sorryThatsNotPossible": {}, + "deviceKeys": "Device keys:", + "@deviceKeys": {}, + "reopenChat": "Reopen chat", + "@reopenChat": {}, + "noBackupWarning": "Warning! Without enabling chat backup, you will lose access to your encrypted messages. It is highly recommended to enable the chat backup first before logging out.", + "@noBackupWarning": {}, + "noOtherDevicesFound": "No other devices found", + "@noOtherDevicesFound": {}, + "fileIsTooBigForServer": "Unable to send! The server only supports attachments up to {max}.", + "@fileIsTooBigForServer": { + "type": "text", + "placeholders": { + "max": {} + } + }, + "fileHasBeenSavedAt": "File has been saved at {path}", + "@fileHasBeenSavedAt": { + "type": "text", + "placeholders": { + "path": {} + } + }, + "jumpToLastReadMessage": "Jump to last read message", + "@jumpToLastReadMessage": {}, + "readUpToHere": "Read up to here", + "@readUpToHere": {}, + "jump": "Jump", + "@jump": {}, + "openLinkInBrowser": "Open link in browser", + "@openLinkInBrowser": {}, + "reportErrorDescription": "😭 Oh no. Something went wrong. If you want, you can report this bug to the developers.", + "@reportErrorDescription": {}, + "report": "report", + "@report": {}, + "signInWithPassword": "Sign in with password", + "@signInWithPassword": {}, + "pleaseTryAgainLaterOrChooseDifferentServer": "Please try again later or choose a different server.", + "@pleaseTryAgainLaterOrChooseDifferentServer": {}, + "signInWith": "Sign in with {provider}", + "@signInWith": { + "type": "text", + "placeholders": { + "provider": {} + } + }, + "profileNotFound": "The user could not be found on the server. Maybe there is a connection problem or the user doesn't exist.", + "@profileNotFound": {}, + "setTheme": "Set theme:", + "@setTheme": {}, + "setColorTheme": "Set color theme:", + "@setColorTheme": {}, + "invite": "Invite", + "@invite": {}, + "inviteGroupChat": "📨 Invite group chat", + "@inviteGroupChat": {}, + "invitePrivateChat": "📨 Invite private chat", + "@invitePrivateChat": {}, + "invalidInput": "Invalid input!", + "@invalidInput": {}, + "wrongPinEntered": "Wrong pin entered! Try again in {seconds} seconds...", + "@wrongPinEntered": { + "type": "text", + "placeholders": { + "seconds": {} + } + }, + "pleaseEnterANumber": "Please enter a number greater than 0", + "@pleaseEnterANumber": {}, + "archiveRoomDescription": "The chat will be moved to the archive. Other users will be able to see that you have left the chat.", + "@archiveRoomDescription": {}, + "roomUpgradeDescription": "The chat will then be recreated with the new room version. All participants will be notified that they need to switch to the new chat. You can find out more about room versions at https://spec.matrix.org/latest/rooms/", + "@roomUpgradeDescription": {}, + "removeDevicesDescription": "You will be logged out of this device and will no longer be able to receive messages.", + "@removeDevicesDescription": {}, + "banUserDescription": "The user will be banned from the chat and will not be able to enter the chat again until they are unbanned.", + "@banUserDescription": {}, + "unbanUserDescription": "The user will be able to enter the chat again if they try.", + "@unbanUserDescription": {}, + "kickUserDescription": "The user is kicked out of the chat but not banned. In public chats, the user can rejoin at any time.", + "@kickUserDescription": {}, + "makeAdminDescription": "Once you make this user admin, you may not be able to undo this as they will then have the same permissions as you.", + "@makeAdminDescription": {}, + "pushNotificationsNotAvailable": "Push notifications not available", + "@pushNotificationsNotAvailable": {}, + "learnMore": "Learn more", + "@learnMore": {}, + "yourGlobalUserIdIs": "Your global user-ID is: ", + "@yourGlobalUserIdIs": {}, + "noUsersFoundWithQuery": "Unfortunately no user could be found with \"{query}\". Please check whether you made a typo.", + "@noUsersFoundWithQuery": { + "type": "text", + "placeholders": { + "query": {} + } + }, + "knocking": "Knocking", + "@knocking": {}, + "chatCanBeDiscoveredViaSearchOnServer": "Chat can be discovered via the search on {server}", + "@chatCanBeDiscoveredViaSearchOnServer": { + "type": "text", + "placeholders": { + "server": {} + } + }, + "searchChatsRooms": "Search for #chats, @users...", + "@searchChatsRooms": {}, + "nothingFound": "Nothing found...", + "@nothingFound": {}, + "groupName": "Group name", + "@groupName": {}, + "createGroupAndInviteUsers": "Create a group and invite users", + "@createGroupAndInviteUsers": {}, + "groupCanBeFoundViaSearch": "Group can be found via search", + "@groupCanBeFoundViaSearch": {}, + "wrongRecoveryKey": "Sorry... this does not seem to be the correct recovery key.", + "@wrongRecoveryKey": {}, + "startConversation": "Start conversation", + "@startConversation": {}, + "commandHint_sendraw": "Send raw json", + "@commandHint_sendraw": {}, + "databaseMigrationTitle": "Database is optimized", + "@databaseMigrationTitle": {}, + "databaseMigrationBody": "Please wait. This may take a moment.", + "@databaseMigrationBody": {}, + "leaveEmptyToClearStatus": "Leave empty to clear your status.", + "@leaveEmptyToClearStatus": {}, + "select": "Select", + "@select": {}, + "searchForUsers": "Search for @users...", + "@searchForUsers": {}, + "pleaseEnterYourCurrentPassword": "Please enter your current password", + "@pleaseEnterYourCurrentPassword": {}, + "newPassword": "New password", + "@newPassword": {}, + "pleaseChooseAStrongPassword": "Please choose a strong password", + "@pleaseChooseAStrongPassword": {}, + "passwordsDoNotMatch": "Passwords do not match", + "@passwordsDoNotMatch": {}, + "passwordIsWrong": "Your entered password is wrong", + "@passwordIsWrong": {}, + "publicLink": "Public link", + "@publicLink": {}, + "publicChatAddresses": "Public chat addresses", + "@publicChatAddresses": {}, + "createNewAddress": "Create new address", + "@createNewAddress": {}, + "joinSpace": "Join space", + "@joinSpace": {}, + "publicSpaces": "Public spaces", + "@publicSpaces": {}, + "addChatOrSubSpace": "Add chat or sub space", + "@addChatOrSubSpace": {}, + "subspace": "Subspace", + "@subspace": {}, + "decline": "Decline", + "@decline": {}, + "thisDevice": "This device:", + "@thisDevice": {}, + "initAppError": "An error occured while init the app", + "@initAppError": {}, + "userRole": "User role", + "@userRole": {}, + "minimumPowerLevel": "{level} is the minimum power level.", + "@minimumPowerLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "searchIn": "Search in chat \"{chat}\"...", + "@searchIn": { + "type": "text", + "placeholders": { + "chat": {} + } + }, + "searchMore": "Search more...", + "@searchMore": {}, + "gallery": "Gallery", + "@gallery": {}, + "files": "Files", + "@files": {}, + "databaseBuildErrorBody": "Unable to build the SQlite database. The app tries to use the legacy database for now. Please report this error to the developers at {url}. The error message is: {error}", + "@databaseBuildErrorBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "sessionLostBody": "Your session is lost. Please report this error to the developers at {url}. The error message is: {error}", + "@sessionLostBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "restoreSessionBody": "The app now tries to restore your session from the backup. Please report this error to the developers at {url}. The error message is: {error}", + "@restoreSessionBody": { + "type": "text", + "placeholders": { + "url": {}, + "error": {} + } + }, + "forwardMessageTo": "Forward message to {roomName}?", + "@forwardMessageTo": { + "type": "text", + "placeholders": { + "roomName": {} + } + }, + "sendReadReceipts": "Send read receipts", + "@sendReadReceipts": {}, + "sendTypingNotificationsDescription": "Other participants in a chat can see when you are typing a new message.", + "@sendTypingNotificationsDescription": {}, + "sendReadReceiptsDescription": "Other participants in a chat can see when you have read a message.", + "@sendReadReceiptsDescription": {}, + "formattedMessages": "Formatted messages", + "@formattedMessages": {}, + "formattedMessagesDescription": "Display rich message content like bold text using markdown.", + "@formattedMessagesDescription": {}, + "verifyOtherUser": "🔐 Verify other user", + "@verifyOtherUser": {}, + "verifyOtherUserDescription": "If you verify another user, you can be sure that you know who you are really writing to. 💪\n\nWhen you start a verification, you and the other user will see a popup in the app. There you will then see a series of emojis or numbers that you have to compare with each other.\n\nThe best way to do this is to meet up or start a video call. 👭", + "@verifyOtherUserDescription": {}, + "verifyOtherDevice": "🔐 Verify other device", + "@verifyOtherDevice": {}, + "verifyOtherDeviceDescription": "When you verify another device, those devices can exchange keys, increasing your overall security. 💪 When you start a verification, a popup will appear in the app on both devices. There you will then see a series of emojis or numbers that you have to compare with each other. It's best to have both devices handy before you start the verification. 🤳", + "@verifyOtherDeviceDescription": {}, + "acceptedKeyVerification": "{sender} accepted key verification", + "@acceptedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "canceledKeyVerification": "{sender} canceled key verification", + "@canceledKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "completedKeyVerification": "{sender} completed key verification", + "@completedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "isReadyForKeyVerification": "{sender} is ready for key verification", + "@isReadyForKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "requestedKeyVerification": "{sender} requested key verification", + "@requestedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "startedKeyVerification": "{sender} started key verification", + "@startedKeyVerification": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "transparent": "Transparent", + "@transparent": {}, + "incomingMessages": "Incoming messages", + "@incomingMessages": {}, + "stickers": "Stickers", + "@stickers": {}, + "discover": "Discover", + "@discover": {}, + "commandHint_ignore": "Ignore the given matrix ID", + "@commandHint_ignore": {}, + "commandHint_unignore": "Unignore the given matrix ID", + "@commandHint_unignore": {}, + "unreadChatsInApp": "{appname}: {unread} unread chats", + "@unreadChatsInApp": { + "type": "text", + "placeholders": { + "appname": {}, + "unread": {} + } + }, + "noDatabaseEncryption": "Database encryption is not supported on this platform", + "@noDatabaseEncryption": {}, + "thereAreCountUsersBlocked": "Right now there are {count} users blocked.", + "@thereAreCountUsersBlocked": { + "type": "text" + }, + "restricted": "Restricted", + "@restricted": {}, + "knockRestricted": "Knock restricted", + "@knockRestricted": {}, + "goToSpace": "Go to space: {space}", + "@goToSpace": { + "type": "text" + }, + "markAsUnread": "Mark as unread", + "userLevel": "{level} - User", + "@userLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "moderatorLevel": "{level} - Moderator", + "@moderatorLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "adminLevel": "{level} - Admin", + "@adminLevel": { + "type": "text", + "placeholders": { + "level": {} + } + }, + "changeGeneralChatSettings": "Change general chat settings", + "inviteOtherUsers": "Invite other users to this chat", + "changeTheChatPermissions": "Change the chat permissions", + "changeTheVisibilityOfChatHistory": "Change the visibility of the chat history", + "changeTheCanonicalRoomAlias": "Change the main public chat address", + "sendRoomNotifications": "Send a @room notifications", + "changeTheDescriptionOfTheGroup": "Change the description of the chat", + "chatPermissionsDescription": "Define which power level is necessary for certain actions in this chat. The power levels 0, 50 and 100 are usually representing users, moderators and admins, but any gradation is possible.", + "updateInstalled": "🎉 Update {version} installed!", + "@updateInstalled": { + "type": "text", + "placeholders": { + "version": {} + } + }, + "changelog": "Changelog", + "sendCanceled": "Sending canceled", + "loginWithMatrixId": "Login with Matrix-ID", + "discoverHomeservers": "Discover homeservers", + "whatIsAHomeserver": "What is a homeserver?", + "homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.", + "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?", + "calculatingFileSize": "Calculating file size...", + "prepareSendingAttachment": "Prepare sending attachment...", + "sendingAttachment": "Sending attachment...", + "generatingVideoThumbnail": "Generating video thumbnail...", + "compressVideo": "Compressing video...", + "sendingAttachmentCountOfCount": "Sending attachment {index} of {length}...", + "@sendingAttachmentCountOfCount": { + "type": "text", + "placeholders": { + "index": {}, + "length": {} + } + }, + "serverLimitReached": "Server limit reached! Waiting {seconds} seconds...", + "@serverLimitReached": { + "type": "text", + "placeholders": { + "seconds": {} + } + }, + "yesterday": "Yesterday" + } + \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 0000000..17a93b4 --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,13 @@ +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter/widgets.dart'; + +export 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +final List supportedLocales = { + const Locale('en'), // make sure 'en' comes first (#216) + ...List.of(AppLocalizations.supportedLocales)..remove(const Locale('en')), +}.toList(); + +extension LocalizationsContext on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e1be005 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:yaru/yaru.dart'; + +import 'app/view/app.dart'; +import 'app_config.dart'; +import 'common/view/ui_constants.dart'; +import 'register.dart'; + +void main() async { + if (isMobilePlatform) { + WidgetsFlutterBinding.ensureInitialized(); + } else { + await YaruWindowTitleBar.ensureInitialized(); + if (!kIsWeb && !Platform.isLinux) { + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + } + } + + registerDependencies(); + + runApp(const NebuchadnezzarApp()); +} diff --git a/lib/register.dart b/lib/register.dart new file mode 100644 index 0000000..d5c438c --- /dev/null +++ b/lib/register.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:matrix/encryption/utils/key_verification.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite/sqflite.dart' as sqlite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../constants.dart'; +import 'chat/authentication/authentication_model.dart'; +import 'chat/bootstrap/bootstrap_model.dart'; +import 'chat/chat_download_model.dart'; +import 'chat/chat_download_service.dart'; +import 'chat/chat_model.dart'; +import 'chat/draft_model.dart'; +import 'chat/local_image_model.dart'; +import 'chat/local_image_service.dart'; +import 'chat/remote_image_model.dart'; +import 'chat/remote_image_service.dart'; +import 'chat/search_model.dart'; + +void registerDependencies() => di + ..registerSingletonAsync( + _ClientX.registerAsync, + dispose: (s) => s.dispose(), + ) + ..registerSingletonWithDependencies( + () => AuthenticationModel( + client: di(), + ), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ) + ..registerLazySingleton( + () => const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ), + ) + ..registerSingletonAsync( + () async => SharedPreferences.getInstance(), + ) + ..registerSingletonWithDependencies( + () => LocalImageService(client: di()), + dependsOn: [Client], + dispose: (s) => s.dispose(), + ) + ..registerSingletonWithDependencies( + () => ChatModel(client: di()), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ) + ..registerSingletonWithDependencies( + () => DraftModel( + client: di(), + localImageService: di(), + ), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ) + ..registerSingletonAsync( + () async { + final service = ChatDownloadService( + client: di(), + preferences: di(), + ); + await service.init(); + return service; + }, + dispose: (s) => s.dispose(), + dependsOn: [Client, SharedPreferences], + ) + ..registerSingletonWithDependencies( + () => ChatDownloadModel(service: di())..init(), + dispose: (s) => s.dispose(), + dependsOn: [ChatDownloadService], + ) + ..registerSingletonWithDependencies( + () => BootstrapModel( + client: di(), + secureStorage: di(), + ), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ) + ..registerSingletonWithDependencies( + () => LocalImageModel( + service: di(), + )..init(), + dispose: (s) => s.dispose(), + dependsOn: [LocalImageService], + ) + ..registerSingletonWithDependencies( + () => RemoteImageService(client: di()), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ) + ..registerSingletonWithDependencies( + () => RemoteImageModel( + service: di(), + )..init(), + dependsOn: [RemoteImageService], + dispose: (s) => s.dispose(), + ) + ..registerSingletonWithDependencies( + () => SearchModel(client: di()), + dispose: (s) => s.dispose(), + dependsOn: [Client], + ); + +extension _ClientX on Client { + static Future registerAsync() async { + if (Platform.isLinux) { + sqlite.Sqflite(); + sqlite.databaseFactoryOrNull = databaseFactoryFfi; + } + final client = Client( + kAppId, + nativeImplementations: kIsWeb + ? const NativeImplementationsDummy() + : NativeImplementationsIsolate(compute), + verificationMethods: { + KeyVerificationMethod.numbers, + if (kIsWeb || Platform.isAndroid || Platform.isIOS || Platform.isLinux) + KeyVerificationMethod.emoji, + }, + databaseBuilder: kIsWeb + ? null + : (_) async { + final dir = await getApplicationSupportDirectory(); + final db = MatrixSdkDatabase( + kAppId, + database: await sqlite + .openDatabase(p.join(dir.path, 'database.sqlite')), + ); + await db.open(); + return db; + }, + ); + // This reads potential credentials that might exist from previous sessions. + await client.init(waitForFirstSync: client.isLogged()); + // await client.firstSyncReceived; + return client; + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..5a53fe6 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,147 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "nebuchadnezzar") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "org.feichtmeier.nebuchadnezzar") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +set(USE_LIBHANDY ON) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..649db1f --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,55 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); + g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "EmojiPickerFlutterPlugin"); + emoji_picker_flutter_plugin_register_with_registrar(emoji_picker_flutter_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) handy_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "HandyWindowPlugin"); + handy_window_plugin_register_with_registrar(handy_window_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); + g_autoptr(FlPluginRegistrar) xdg_icons_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "XdgIconsPlugin"); + xdg_icons_plugin_register_with_registrar(xdg_icons_registrar); + g_autoptr(FlPluginRegistrar) yaru_window_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "YaruWindowLinuxPlugin"); + yaru_window_linux_plugin_register_with_registrar(yaru_window_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..d3c91b3 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,34 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + dynamic_color + emoji_picker_flutter + file_selector_linux + flutter_secure_storage_linux + gtk + handy_window + screen_retriever_linux + url_launcher_linux + window_manager + xdg_icons + yaru_window_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000..f8911d9 --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,94 @@ +#include "my_application.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(hdy_application_window_new()); + gtk_window_set_application(window, GTK_APPLICATION(application)); + + gtk_window_set_default_size(window, 800, 600); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..1b1e774 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,38 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import appkit_ui_element_colors +import dynamic_color +import emoji_picker_flutter +import file_selector_macos +import flutter_secure_storage_macos +import macos_ui +import macos_window_utils +import path_provider_foundation +import screen_retriever_macos +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import video_compress +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppkitUiElementColorsPlugin.register(with: registry.registrar(forPlugin: "AppkitUiElementColorsPlugin")) + DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + MacOSUiPlugin.register(with: registry.registrar(forPlugin: "MacOSUiPlugin")) + MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..5ad4cc9 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,46 @@ +platform :osx, '10.14.6' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.14' + end + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..f0c87e6 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,103 @@ +PODS: + - appkit_ui_element_colors (1.0.0): + - FlutterMacOS + - dynamic_color (0.0.2): + - FlutterMacOS + - emoji_picker_flutter (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - flutter_secure_storage_macos (6.1.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - macos_ui (0.1.0): + - FlutterMacOS + - macos_window_utils (1.0.0): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - video_compress (0.3.0): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - appkit_ui_element_colors (from `Flutter/ephemeral/.symlinks/plugins/appkit_ui_element_colors/macos`) + - dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`) + - emoji_picker_flutter (from `Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - macos_ui (from `Flutter/ephemeral/.symlinks/plugins/macos_ui/macos`) + - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +EXTERNAL SOURCES: + appkit_ui_element_colors: + :path: Flutter/ephemeral/.symlinks/plugins/appkit_ui_element_colors/macos + dynamic_color: + :path: Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos + emoji_picker_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + macos_ui: + :path: Flutter/ephemeral/.symlinks/plugins/macos_ui/macos + macos_window_utils: + :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + video_compress: + :path: Flutter/ephemeral/.symlinks/plugins/video_compress/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + appkit_ui_element_colors: 39bb2d80be3f19b152ccf4c70d5bbe6cba43d74a + dynamic_color: 2eaa27267de1ca20d879fbd6e01259773fb1670f + emoji_picker_flutter: 533634326b1c5de9a181ba14b9758e6dfe967a20 + file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d + flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + macos_ui: 6229a8922cd97bafb7d9636c8eb8dfb0744183ca + macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + +PODFILE CHECKSUM: ca2cf31979f9f90700f5893415c4c6d79c3ecb58 + +COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f60070f --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,805 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 018C3B005625B11FF99C7E84 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2850CA6D56C00982150FE17 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5511186D89978E689A9EC02B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91FDB8A5A757715D0B755358 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 2E38E43436073B5F3B449CAF /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* nebuchadnezzar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nebuchadnezzar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3A9E1587B289F04110B20B95 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 43DC6F438E46E89039B2B1B7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 655544927E4615EABDA10EA4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 79EB7F0D6080AD95C1B0B49C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 91FDB8A5A757715D0B755358 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B2850CA6D56C00982150FE17 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CD1CCE05860ED776DE6F2B35 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 018C3B005625B11FF99C7E84 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5511186D89978E689A9EC02B /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 4A572FFF46344F43BD17D75E /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* nebuchadnezzar.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 4A572FFF46344F43BD17D75E /* Pods */ = { + isa = PBXGroup; + children = ( + 43DC6F438E46E89039B2B1B7 /* Pods-Runner.debug.xcconfig */, + CD1CCE05860ED776DE6F2B35 /* Pods-Runner.release.xcconfig */, + 655544927E4615EABDA10EA4 /* Pods-Runner.profile.xcconfig */, + 79EB7F0D6080AD95C1B0B49C /* Pods-RunnerTests.debug.xcconfig */, + 2E38E43436073B5F3B449CAF /* Pods-RunnerTests.release.xcconfig */, + 3A9E1587B289F04110B20B95 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 91FDB8A5A757715D0B755358 /* Pods_Runner.framework */, + B2850CA6D56C00982150FE17 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + CC1500D8A0512F9C8A396030 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 65B8C7C9FBB5AC450188D651 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 27CC0E3E9C8781CAE6ACFE2D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* nebuchadnezzar.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 27CC0E3E9C8781CAE6ACFE2D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 65B8C7C9FBB5AC450188D651 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CC1500D8A0512F9C8A396030 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 79EB7F0D6080AD95C1B0B49C /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.feichtmeier.nebuchadnezzar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nebuchadnezzar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nebuchadnezzar"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2E38E43436073B5F3B449CAF /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.feichtmeier.nebuchadnezzar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nebuchadnezzar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nebuchadnezzar"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A9E1587B289F04110B20B95 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.feichtmeier.nebuchadnezzar.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nebuchadnezzar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nebuchadnezzar"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = Y7ZGTYFNR6; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = Y7ZGTYFNR6; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = Y7ZGTYFNR6; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..427962c --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..dc870aa1415094afea46e8a7130542a325b2a5b7 GIT binary patch literal 77746 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 T;Xzv;Y^;UP4NE%EwNFp literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..cc592982a636e9fad627f343821a2d2519f9e1bc GIT binary patch literal 5692 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 P#uz+Z{an^LB{Ts5@!Mv~ literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..c17fd70f1cfd4bc528da652756747d9ca8feec7b GIT binary patch literal 12188 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 z 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 literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..c4de81b833858612d314d21582605be8e518f3cd GIT binary patch literal 29153 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 LKVNY6cF_L=hIiH+ literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..326b6f68d83e2366e5e53a5ba38be2971ede8b49 GIT binary patch literal 2782 zcmV<43L*80P)wrAXI2& zZDk--Wo>Y5VRU6-pWDg+003rmMObt}b#!QNasXLja&;h7WnpbBAYx&2Wn>_4ZXk4M zWgt^^a&;hdWo>Y5VRU66VtF7_VQnBtWp`kQglUFbVF}&d2(rIXmkKWLm)wPbaH88 zb#!TOZY@)2VRB_bY-wv{AZT=Sa5^t9V{&C-bZK^FV{dJ3Z*FrgZ*pfZY-wXT4-u&3T*Ex07`UVU;u`OhR}^0Hz1qM zBJCD(YhQt2YF$%PgZJ#&1BVYE#yvefOfYsh1kl&lM=vBv5>LKzr9(J}f#xvJp(*uApLIoM4+@~2SJ(_uu20 z>#tuK`}1fjef#X$vjt+xN`HU9Qvg)eHS*$#r@DUn()Zh6=CWxKaE8c|)Xr<81WnYf z>_y(qup+g!2wq>5I8oVnD+dj&o8e2{P27{ecyj;g7cbuVXY$@#qpA51 zE?l^v3=R&m+lBxxU%pHq;eWJu+flBN^(Jp%1CTdqC*c(P@wc}g?M==9ns6-=U_lU6 z0VI=2SXfwK0_bk7{-$?_D-+#FSk0463T^hf#DdSUy< zC+}WvZREFux|^Z#@o_K&Km#4E8P{ARG^$?DeWE3k2YcOmkaus}yeUSwsoQ!clVR0N z0F#rGdUK9PN|k%(YeYVRmaHXt3J&3f{!P<*RRF{xn4gJ#9?7#J!j8btUV$u+e2&O# zWn`4@)#gq^c`SbLAc@KGT`)wP)uF~CG{`sT>TbEgLg*AgFLrm z9|%wXhIOumS0OUa)h0=f^n{R#$Dm9UcHh%@YR^E*~MX(8&$TOuEYoNS3?+r}mM(4Pa zs?t8(oTLVS6Ru z`Yc;@oxF)<2=WvmLSZxcU>}h0eBOWdc0>{YX`5eUYHzGMn29Uaui#VG5D*Hx?V;uI z0W|M`naWG*{!T7zL_OGjS|OrX-=wH(CuMMgOy#9HH4njvn^oRpJ|RPR&!nhpCm~E= zt$_=RMC})V{iMfKURQen*OhlQfk>gScLI~ifT|!bMxd}dV7l6L)n)oeS=>sH&o0wE z{SZK&B77X6mzQS&3)N)5tJ79w9YD7>DQ&2{)!}*AJ7_}l^I@fg!U(D6ujI4rT#aZp zR;(l1P!)w*AaH>MHTjHscGqkjBOjGFg9Stonmk3QP}qnos1W8-fVOs$Ns$4h3}F5t z(S~|;KQ0ASx=iJBs-pw5y<-TY@_6yCHtjVGg;(-vSQYM_e*kXTrL7QR+Y%I~S;hr~ zTJX$1`b}IZ0AhhnaYfb5wjRRVX%qCxHD~^>lu+1A-gsbk45Vv+1ZY19d}FJYZ6UTt zy>;;+F^dMWa1oqjk(g9Iqm5&>_h7Ici7bG!_$eWO6~x7H>n>1ADC|a_Ee!b=pZNgr z{hJ_@3COk#EO119JAMG%u4hOe-e$q}_OVNB;@E9|9^+ff>d$y0hv=}@XtH$WwSY!4iL^ z1yzs&mVmqYD&(i$2k0Osar>{3So%nFpw6y7Yw{E>POx| zdADqqT?6!x%j%t?lBcBOdS2do-<`ZiDt;|j9)YU_940`tpg>GDzH0KZYExd`dcUmX zfrV>Du*H>30H$|qup}$FYMzgS9YN%2BqC}m?{dG~b}`6;SG$Webk>cR>nZZ*}Z9BQC%9QuAcWC$W8-V;`u26XW%DumkVsZEG-B|Z~#x8LD z_;Gyt^l5owRvJ6`r#rpBd#C(IDfp2nb2^ zykp^MjO$j5Y@|XG^A`fPeAUt>m=G@JWt^Lum*(%z-R5yJe5n5`_hgb zJCswWP8r=90-&#)4h;?A0|yQir>CcLskC(Cl`B)DufUX`wbNguv`?v$uyQ6>ZM~>n z1^U_C+$>X$l&cO)AVeKQGMU7EeSO7&fdM(2&C&;v>C#c@0?^Css;Q|_dU|@;Tch^( zi5-z3Z9q%``uqFw@bIuqU#O(7mzZ9ju2k|2WOcFr@-*Gv>AS=G_wNVus+-|?8l(># kI&_F#Hk9Yb!F9s_03%4XZ&IfnRsaA107*qoM6N<$f`cPDWdHyG literal 0 HcmV?d00001 diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..13b880b --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = nebuchadnezzar + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = org.feichtmeier.nebuchadnezzar + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 org.feichtmeier. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..d769771 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + keychain-access-groups + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..1cd0e14 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.server + + com.apple.security.network.client + + keychain-access-groups + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/needs_translation.json b/needs_translation.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/needs_translation.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..cad52df --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1741 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "88399e291da5f7e889359681a8f64b18c5123e03576b01f32a6a276611e511c3" + url: "https://pub.dev" + source: hosted + version: "78.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + adaptive_dialog: + dependency: "direct main" + description: + name: adaptive_dialog + sha256: "760acece71b957dbab6f2071b84eb52b4b0236f20ba6cfb89446834ee92086dc" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "62899ef43d0b962b056ed2ebac6b47ec76ffd003d5f7c4e4dc870afe63188e33" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + animated_emoji: + dependency: "direct main" + description: + name: animated_emoji + sha256: "0af5508ce0ccb44caa6d3776d01900be6f6e70676881bfa8ce1a1bb9bf91a6a7" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + animated_vector: + dependency: transitive + description: + name: animated_vector + sha256: f1beb10e6fcfd8bd15abb788e20345def786d1c7391d7c1426bb2a1f2adf2132 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + animated_vector_annotations: + dependency: transitive + description: + name: animated_vector_annotations + sha256: "07c1ea603a2096f7eb6f1c2b8f16c3c330c680843ea78b7782a3217c3c53f979" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + animations: + dependency: transitive + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" + appkit_ui_element_colors: + dependency: transitive + description: + name: appkit_ui_element_colors + sha256: c3e50f900aae314d339de489535736238627071457c4a4a2dbbb1545b4f04f22 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + assorted_layout_widgets: + dependency: transitive + description: + name: assorted_layout_widgets + sha256: fe35ef80d0fb304bec8d0f600bea57d60443718748add32124e350739477614f + url: "https://pub.dev" + source: hosted + version: "10.0.4" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + blurhash_dart: + dependency: transitive + description: + name: blurhash_dart + sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + url: "https://pub.dev" + source: hosted + version: "8.9.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + cached_network_svg_image: + dependency: "direct main" + description: + name: cached_network_svg_image + sha256: fe9df0217c12e3903558dad14e1bb938c51296a1d96faa080415c6146bbd7a7d + url: "https://pub.dev" + source: hosted + version: "1.2.0" + cached_value: + dependency: transitive + description: + name: cached_value + sha256: "534a6c618e2e891548236c90a3a3ac055dfe4c870efff29719cf28c51ca8b7a5" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + canonical_json: + dependency: transitive + description: + name: canonical_json + sha256: d6be1dd66b420c6ac9f42e3693e09edf4ff6edfee26cb4c28c1c019fdb8c0c15 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + url: "https://pub.dev" + source: hosted + version: "0.17.3" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + dio: + dependency: "direct main" + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + dynamic_color: + dependency: transitive + description: + name: dynamic_color + sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d + url: "https://pub.dev" + source: hosted + version: "1.7.0" + emoji_picker_flutter: + dependency: "direct main" + description: + name: emoji_picker_flutter + sha256: "08567e6f914d36c32091a96cf2f51d2558c47aa2bd47a590dc4f50e42e0965f6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + enhanced_enum: + dependency: transitive + description: + name: enhanced_enum + sha256: "074c5a8b9664799ca91e1e8b68003b8694cb19998671cbafd9c7779c13fcdecf" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: "direct main" + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: c2376a6aae82358a9f9ccdd7d1f4006d08faa39a2767cce01031d9f593a8bd3b + url: "https://pub.dev" + source: hosted + version: "8.1.6" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "98ac58e878b05ea2fdb204e7f4fc4978d90406c9881874f901428e01d3b18fbc" + url: "https://pub.dev" + source: hosted + version: "0.5.1+12" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "94b98ad950b8d40d96fee8fa88640c2e4bd8afcdd4817993bd04e20310f45420" + url: "https://pub.dev" + source: hosted + version: "0.5.3+1" + file_selector_linux: + dependency: "direct main" + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4" + url: "https://pub.dev" + source: hosted + version: "0.9.3+3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_highlighter: + dependency: "direct main" + description: + name: flutter_highlighter + sha256: "93173afd47a9ada53f3176371755e7ea4a1065362763976d06d6adfb4d946e10" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee" + url: "https://pub.dev" + source: hosted + version: "3.0.0-beta.2" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "31cd0885738e87c72d6f055564d37fabcdacee743b396b78c7636c169cac64f5" + url: "https://pub.dev" + source: hosted + version: "0.14.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_olm: + dependency: "direct main" + description: + name: flutter_olm + sha256: "5e6211af8cba1abf7d1f92e543f6d573dfe6017fe4742e0d04ba84beab47f940" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_openssl_crypto: + dependency: "direct main" + description: + name: flutter_openssl_crypto + sha256: "293b4fcda13ab0710645a16e82f3d5b7de19bfc0ab2d06bcdb87637222eda5e1" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + url: "https://pub.dev" + source: hosted + version: "9.2.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" + url: "https://pub.dev" + source: hosted + version: "2.0.16" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + functional_listener: + dependency: transitive + description: + name: functional_listener + sha256: c096db771b4ce7ba0f886cc4a761044b11e8276a7bc24cfc812dc4b2bc6f5b16 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + future_loading_dialog: + dependency: "direct main" + description: + name: future_loading_dialog + sha256: "2718b1a308db452da32ab9bca9ad496ff92b683e217add9e92cf50520f90537e" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + get_it: + dependency: transitive + description: + name: get_it + sha256: f126a3e286b7f5b578bf436d5592968706c4c1de28a228b870ce375d9f743103 + url: "https://pub.dev" + source: hosted + version: "8.0.3" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + gradient_borders: + dependency: transitive + description: + name: gradient_borders + sha256: b1cd969552c83f458ff755aa68e13a0327d09f06c3f42f471b423b01427f21f8 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + handy_window: + dependency: "direct main" + description: + name: handy_window + sha256: "56b813e58a68b0ee2ab22051400b8b1f1b5cfe88b8cd32288623defb3926245a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + highlighter: + dependency: transitive + description: + name: highlighter + sha256: "92180c72b9da8758e1acf39a45aa305a97dcfe2fdc8f3d1d2947c23f2772bfbc" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + html: + dependency: "direct main" + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + intersperse: + dependency: transitive + description: + name: intersperse + sha256: "2f8a905c96f6cbba978644a3d5b31b8d86ddc44917662df7d27a61f3df66a576" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + linkify: + dependency: "direct main" + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + liquid_progress_indicator_v2: + dependency: "direct main" + description: + name: liquid_progress_indicator_v2 + sha256: "6bb2c675bab4936864a63ccd503be417e407974e11c62711917a4006bb9288b8" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lottie: + dependency: transitive + description: + name: lottie + sha256: "377d87b8dcef640c04717e93afb86a510f0e1117a399ab94dc4b3f39c85eaa87" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + macos_ui: + dependency: transitive + description: + name: macos_ui + sha256: "80f6539aba5a3a1182d5225a6c27969a780bcb1d2d8135b4ffb708570cf0c854" + url: "https://pub.dev" + source: hosted + version: "2.0.9" + macos_window_utils: + dependency: transitive + description: + name: macos_window_utils + sha256: "3534f2af024f2f24112ca28789a44e6750083f8c0065414546c6593ee48a5009" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + matrix: + dependency: "direct main" + description: + name: matrix + sha256: de99186797fddbf309dae0d9b9b4d35b49ca10d7bb362727f7cd916ce71a77d7 + url: "https://pub.dev" + source: hosted + version: "0.36.0" + matrix4_transform: + dependency: transitive + description: + name: matrix4_transform + sha256: "1346e53517e3081d3e8362377be97e285e2bd348855c177eae2a18aa965cafa0" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + mesh: + dependency: "direct main" + description: + name: mesh + sha256: "55fa011bbe3f2190b6c4b49950f6fa7ca279918f497a88d909a521c5b4b8efc1" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + olm: + dependency: transitive + description: + name: olm + sha256: "3306bf534ceb914fd148b3b4a3d603fb5e067b2e6da8304025b47c24cfdf6b46" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + platform_linux: + dependency: transitive + description: + name: platform_linux + sha256: "856cfc9871e3ff3df6926991729d24bba9b70d0229ae377fa08b562344baaaa8" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + random_string: + dependency: transitive + description: + name: random_string + sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + safe_change_notifier: + dependency: "direct main" + description: + name: safe_change_notifier + sha256: e7cce266bfede647355866fa3bd054feda57c220d2383f4203f28d4dcdb3b82e + url: "https://pub.dev" + source: hosted + version: "0.4.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "3c7e73920c694a436afaf65ab60ce3453d91f84208d761fbd83fc21182134d93" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "02a7d8a9ef346c9af715811b01fbd8e27845ad2c41148eefd31321471b41863d" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slugify: + dependency: transitive + description: + name: slugify + sha256: b272501565cb28050cac2d96b7bf28a2d24c8dae359280361d124f3093d337c3 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" + url: "https://pub.dev" + source: hosted + version: "2.3.4+4" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5 + url: "https://pub.dev" + source: hosted + version: "2.5.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" + url: "https://pub.dev" + source: hosted + version: "1.1.15" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + url: "https://pub.dev" + source: hosted + version: "1.1.12" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + url: "https://pub.dev" + source: hosted + version: "1.1.16" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + video_compress: + dependency: "direct main" + description: + name: video_compress + sha256: "5b42d89f3970c956bad7a86c29682b0892c11a4ddf95ae6e29897ee28788e377" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + watch_it: + dependency: "direct main" + description: + name: watch_it + sha256: cdde70641e090d6ab8cf80476bb6346fb9b6c85791ab7f7904e28d8736fa3d44 + url: "https://pub.dev" + source: hosted + version: "1.6.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: abec3ab7956bd5ac539cf34a42fa0c82ea26675847c0966bb85160400eea9388 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + xdg_directories: + dependency: "direct main" + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xdg_icons: + dependency: "direct main" + description: + name: xdg_icons + sha256: "0a52abfa00aae6f09c2d946210825b7c6e0d696af5f15a8818e3baac3b44ea61" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaru: + dependency: "direct main" + description: + path: "." + ref: "4a943961ad6c1d382b1bfc3bc0e67bb9f6440089" + resolved-ref: "4a943961ad6c1d382b1bfc3bc0e67bb9f6440089" + url: "https://github.com/ubuntu/yaru.dart" + source: git + version: "6.0.0" + yaru_window: + dependency: transitive + description: + name: yaru_window + sha256: bc2a1df3c6f33477b47f84bf0a9325df411dbb7bd483ac88e5bc1c019d2f2560 + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + yaru_window_linux: + dependency: transitive + description: + name: yaru_window_linux + sha256: "46a1a0743dfd45794cdaf8c5b3a48771ab73632b50a693f59c83b07988e96689" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + yaru_window_manager: + dependency: transitive + description: + name: yaru_window_manager + sha256: b36c909fa082a7cb6e2f259d4357e16f08d3d8ab086685b81d1916e457100d1e + url: "https://pub.dev" + source: hosted + version: "0.1.2+1" + yaru_window_platform_interface: + dependency: transitive + description: + name: yaru_window_platform_interface + sha256: "93493d7e17a9e887ffa94c518bc5a4b3eb5425c009446e3294c689cb1a87b7e1" + url: "https://pub.dev" + source: hosted + version: "0.1.2+1" + yaru_window_web: + dependency: transitive + description: + name: yaru_window_web + sha256: "31468aeb515f72d5eeddcd62773094a4f48fee96f7f0494f8ce53ad3b38054f1" + url: "https://pub.dev" + source: hosted + version: "0.0.3+1" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.1" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..ff8ff68 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,84 @@ +name: nebuchadnezzar +description: "A new Flutter project." + +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ">=3.4.4 <4.0.0" + flutter: ">=3.27.1" + +dependencies: + adaptive_dialog: ^2.3.0 + animated_emoji: ^3.1.0 + cached_network_image: ^3.4.1 + cached_network_svg_image: ^1.2.0 + collection: ^1.18.0 + cross_file: ^0.3.4+2 + cupertino_icons: ^1.0.8 + dio: ^5.7.0 + emoji_picker_flutter: ^3.1.0 + file: ^7.0.1 + file_picker: ^8.1.6 + file_selector: ^1.0.3 + file_selector_linux: ^0.9.2 + flutter: + sdk: flutter + flutter_cache_manager: ^3.4.1 + flutter_highlighter: ^0.1.1 + flutter_html: ^3.0.0-beta.2 + flutter_localizations: + sdk: flutter + flutter_olm: ^2.0.0 + flutter_openssl_crypto: ^0.5.0 + flutter_secure_storage: ^9.2.2 + flutter_svg: ^2.0.16 + future_loading_dialog: ^0.3.0 + handy_window: ^0.4.0 + html: ^0.15.4 + intl: ^0.19.0 + linkify: ^5.0.0 + liquid_progress_indicator_v2: ^0.5.0 + matrix: ^0.36.0 + mesh: ^0.4.2 + mime: ^2.0.0 + path: ^1.9.0 + path_provider: ^2.1.4 + safe_change_notifier: ^0.4.0 + scroll_to_index: ^3.0.1 + shared_preferences: ^2.3.3 + shimmer: ^3.0.0 + sqflite: ^2.4.1 + sqflite_common_ffi: ^2.3.4 + url_launcher: ^6.3.1 + video_compress: ^3.1.3 + watch_it: ^1.6.1 + window_manager: ^0.4.3 + xdg_directories: ^1.1.0 + xdg_icons: ^0.1.0 + yaru: + git: + url: https://github.com/ubuntu/yaru.dart + ref: 4a943961ad6c1d382b1bfc3bc0e67bb9f6440089 + +dev_dependencies: + build_runner: ^2.4.8 + flutter_launcher_icons: ^0.14.2 + flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter + +flutter: + generate: true + uses-material-design: true + assets: + - assets/ + +flutter_launcher_icons: + android: false + ios: false + image_path: "assets/nebuchadnezzar.png" + macos: + generate: true + image_path: "assets/nebuchadnezzar.png" \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..f009bd1 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nebuchadnezzar/app/view/app.dart'; + +void main() { + testWidgets('Test', (WidgetTester tester) async { + await tester.pumpWidget(const App()); + + expect(find.byType(MaterialApp), findsOneWidget); + }); +}